Repository navigation
Feature Request: sqlmesh plan performs full project loading for every non-prod environment plan #5906
Description
Summary
Every sqlmesh plan <env> where <env> is not prod loads, parses, and processes the entire project — all models, macros, audits, tests, and snapshots — regardless of how many models actually changed. Dev workflows, where engineers iterate on a small subset of models, pay a cost that scales with total project size rather than with the size of the change.
Where this happens
1. Full filesystem load is unconditional
GenericContext.__init__ always calls self.load():
sqlmesh/core/context.py ~L473:
if load:
self.load()load() parses every model, macro, audit, test, and metric across all project paths, then runs update_model_schemas() on the full DAG and model.validate_definition() on every model (context.py ~L629–728). There's no way to load only the changed subgraph.
2. Full prod state read during load()
Before plan() is even called, all prod snapshots are fetched from the state backend to populate remote-only models:
sqlmesh/core/context.py ~L687–699:
prod = self.state_reader.get_environment(c.PROD)
if prod:
for snapshot in self.state_reader.get_snapshots(prod.snapshots).values():
...3. Linting and tests run on all models
lint_models() and _run_plan_tests() run against the full model set before the plan is built, with no scoping to changed models:
sqlmesh/core/context.py ~L1579–1582.
4. Full snapshot creation with a state round-trip
_snapshots() creates snapshots for every model/standalone audit, then bulk-fetches all of them from state:
sqlmesh/core/context.py ~L2814–2842:
nodes = {**(models_override or self._models), **self._standalone_audits}
snapshots = self._nodes_to_snapshots(nodes)
stored_snapshots = self.state_reader.get_snapshots(snapshots.values())Even with models_override (e.g. via --select-model), it still contains every model — selected ones from local state, the rest sourced from remote.
5. Full context diff against remote state
ContextDiff.create() fetches the entire target environment and bulk-reads all local + all modified remote snapshots:
sqlmesh/core/context_diff.py ~L180–182:
stored = state_reader.get_snapshots(
[*snapshots.values(), *modified_snapshot_name_to_snapshot_info.values()]
)6. Full DAG construction and traversal
PlanBuilder._build_dag() iterates every snapshot in the context diff, not just the changed subgraph:
sqlmesh/core/plan/builder.py ~L363–367:
for s_id, context_snapshot in self._context_diff.snapshots.items():
dag.add(s_id, context_snapshot.parents)This full DAG is then traversed by _build_directly_and_indirectly_modified, _build_restatements, _build_models_to_backfill (dag.subdag(...) only runs after the full DAG exists), and earliest_interval_start (iterates all snapshots).
7. Dev pays more than prod for DeployabilityIndex
Prod uses the constant-time DeployabilityIndex.all_deployable(); dev iterates every snapshot via DeployabilityIndex.create():
sqlmesh/core/plan/builder.py ~L298–306:
deployability_index = (
DeployabilityIndex.create(
self._context_diff.snapshots.values(), # ALL snapshots
start=self._start,
start_override_per_model=self._start_override_per_model,
)
if self._is_dev
else DeployabilityIndex.all_deployable()
)Dev — the most frequent workflow — is disproportionately penalized.
What dev plans actually need
Only the changed models, their upstream dependencies, and their downstream dependents matter. Everything else in the project is irrelevant, but the current implementation has no concept of this subgraph.
The one scoping that does exist: in dev without --include-unmodified, backfill_models is limited to modified/added model names (context.py ~L1659–1667). This only limits what gets backfilled — it does not reduce the work done to produce the plan.
Impact
- Large projects:
sqlmesh plan devafter a one-model change still parses every model, validates every definition, creates every snapshot, and issues multiple full-project state reads. Cost scales with project size, not change size. - Dev iteration is disproportionately penalized: dev is the most frequent workflow, yet pays more per-plan than prod due to
DeployabilityIndex.createiterating all snapshots. - State backend load: multiple full
get_snapshots()calls per plan (duringload()for prod state, in_snapshots(), and inContextDiff.create()) — expensive for networked/DB-backed state stores. - Linting/tests unscoped: run over the full project even when only one model changed.
Suggested direction
For non-prod environments, determine the changed subgraph first, then scope all subsequent work to it:
- Change detection before full load — identify changed files (e.g. via mtimes or git diff) to determine the minimal subgraph to load.
- Lazy/partial context loading — let the
Loaderload only a specified subgraph, representing unrelated models as lightweight stubs sourced from remote state. - Scoped snapshot creation —
_snapshots()should only build snapshots for the changed subgraph; unrelated models reuse their stored fingerprints from state. - Scoped context diff —
ContextDiff.createshould operate on a subgraph snapshot dict and fetch only the relevant remote snapshots. - Scoped DAG —
PlanBuilder._build_dag()should build a DAG scoped to the changed subgraph in dev, not always the full project DAG. - Scoped linting/tests —
lint_models()and_run_plan_tests()should be scoped to the changed subgraph for dev plans. - Scoped
DeployabilityIndex— scopeDeployabilityIndex.createin dev to the modified subgraph to close the gap with prod'sall_deployable().
References
- Context init/unconditional load:
sqlmesh/core/context.py~L473–474 load()full parse + schema update:sqlmesh/core/context.py~L629–753- Prod state read during
load():sqlmesh/core/context.py~L687–699 plan_builder()linting/tests/snapshots:sqlmesh/core/context.py~L1564–1667_snapshots()full creation + state read:sqlmesh/core/context.py~L2814–2842ContextDiff.create()full state diff:sqlmesh/core/context_diff.py~L92–230PlanBuilder.build()full DAG construction:sqlmesh/core/plan/builder.py~L280–367DeployabilityIndexdev vs prod branching:sqlmesh/core/plan/builder.py~L298–306
Activity
[-]Feature Request: sqlmesh plan performs full project loading for every non-prod environment plan[/-][+]Feature Request: `sqlmesh plan` performs full project loading for every non-prod environment plan[/+]Ajay-Satish-01 commentedon Aug 29, 2026
May I work on this?
fresioAS commentedon Aug 30, 2026
@Ajay-Satish-01 i have a proposed (draft) PR here - #6002 - but feel free to improve or test if I have missed something - I have not tested it in proper environment yet - which is why it is still in draft
Ajay-Satish-01 commentedon Aug 30, 2026
Thanks for the heads-up. I will look into your PR and also try testing/improving it.
fresioAS commentedon Sep 2, 2026
I tested timings with #6002 but did not find any substantial speedup. The LLM however gave me two other pointers for where we could speed things up in a large project #6015 and #6016 - I will test these out in a controlled environment when I get the time
cmgoffena13 commentedon Sep 4, 2026
Just curiosity reading here, but in regards to step 3 of your explanation, I recently contributed #5966 -- which is a flag to only run tests for changed models in the plan. I didn't realize lint runs on all the models as well. We could probably look into implementing another flag
--lint-changed-onlyfor plans!