cursor for sql is the phrase that quietly rewrote the senior data engineer's job description in 2026 — the SQL you author, the dbt model you scaffold, the Airflow DAG you wire up, and the pytest fixture you generate now travel through an LLM-native editor before they ever hit the compile step, and the engineers who have learned to drive that editor ship two or three times the volume of production-grade SQL that engineers who still hand-type every window function do. The productivity delta is not marginal; it is a step change, and it is measurable in PR throughput, in incident-response time, and in the size of the analytics-engineering roadmap a single senior can realistically own. The editor is no longer a text buffer with syntax highlighting — it is a small autonomous collaborator that reads your repo, remembers your dbt conventions, and drafts your next SELECT, task_group, or dbt macro before your fingers have finished typing the docstring.
This guide is the senior-DE walkthrough you wished existed the first time an interviewer asked "walk me through how you configure Cursor for a dbt monorepo," or "how do you keep github copilot sql from hallucinating column names against a schema it hasn't seen," or "how would you use an LLM to co-author an Airflow DAG that ingests from a Snowflake external stage." It walks through the four canonical LLM-authoring patterns — the .cursorrules file as the single source of truth for repo prompts, the comment-first Copilot inline pattern that pins ghost-text to a natural-language contract, the docstring-first Airflow DAG scaffold that makes the LLM propose sensors and task groups, and the three-lane guardrail (dbt compile → sqlfluff lint → human review) that protects the merge — plus the failure modes that make senior interviewers probe for review discipline more than for prompt cleverness. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
1. Why LLM-native editors changed SQL authoring in 2026
The editor is now the LLM — four axes that separate a productive workflow from a hallucinating one
The one-sentence invariant: an LLM-native editor is not "autocomplete with more parameters" but a small autonomous collaborator that reads your repo, keeps a rolling context window of your dbt model graph and Airflow DAG shape, drafts code against natural-language prompts you either write explicitly or leak accidentally through comments and docstrings, and either accelerates your senior SQL work by 2–3× or ships subtly-wrong SQL every day depending entirely on whether you have configured repo context, comment discipline, and review guardrails. The tools — Cursor, GitHub Copilot, Copilot Chat, Copilot Workspace, Codex CLI, Windsurf — differ in ergonomics, latency, and agentic ability but converge on the same four axes senior interviewers now probe: how the LLM sees your repo, how you frame the prompt, how autonomously it acts, and how the compile / lint / review gates catch what it gets wrong.
The four LLM-editor axes interviewers actually probe.
Repo context. Cursor's .cursorrules file plus the @Codebase / @Docs symbol injection gives the LLM a persistent, versioned repo prompt. GitHub Copilot inline uses the current file plus a handful of recently-opened files as its "context"; Copilot Chat can pull in @workspace and @github. The width and freshness of that context window is the single strongest predictor of hallucination rate — narrow context means the LLM invents column names, wide-but-stale context means it drafts against dbt models that were renamed last month.
Inline latency. Ghost-text completions must arrive in 200–400 ms or the flow breaks and you fall back to typing. Cursor's small-model tab completion is optimised for this; Copilot's inline model is similar. Copilot Chat and Cursor Composer intentionally trade latency for depth — you accept 3–10 s in exchange for multi-file edits.
Agentic ability. How much does the LLM do without asking? Cursor Composer, Copilot Workspace, and Codex CLI open, read, edit, and run files across the repo; inline Copilot only completes at the caret. Agentic mode is where senior data engineers gain the biggest velocity multiplier — and where the review discipline must be strongest.
Guardrails. Precommit hooks (sqlfluff, dbt parse, dbt compile), CI checks (dbt test, pytest, sqlmesh diff), and PR review conventions are the only defenses against the LLM's failure modes. An engineer who cannot recite their three-lane guardrail out loud is not senior — they are lucky.
Cursor vs GitHub Copilot vs Copilot Chat vs Codex CLI — what each is best at.
Cursor. VS Code fork with first-class repo context, .cursorrules, @Codebase/@Docs symbol injection, and the Composer agentic mode. Best fit: dbt monorepos, analytics-engineering teams, iterative model refactors that touch a dozen files. The .cursorrules file is the load-bearing config — set it well and hallucination rates drop measurably.
GitHub Copilot (inline). Fastest ghost-text completion; deep VS Code / JetBrains / Vim integration; excellent for line-by-line SQL and dbt macro authoring. Weak on repo-wide refactors — it doesn't know models it hasn't seen in the current session unless you pull them into scope explicitly.
Copilot Chat. Conversational assistant with @workspace, @github, @vscode symbols. Best fit: "explain this DAG," "generate a test for this dbt model," "refactor this window function." Slower than inline; more thoughtful.
Codex CLI / Claude Code / Aider. Terminal-native agents that read, edit, and run shell / SQL / pytest across the repo. Best fit: overnight refactors, one-shot data-quality audits, automated dbt exposure generation. Requires the strongest guardrails.
2026 reality — the SQL engineer's editor is now the LLM.
Every senior data-engineering interview loop in 2026 that touches "how do you work day-to-day?" includes an LLM-workflow probe. The bare minimum answer is: "I use Cursor + Copilot with a .cursorrules file, comment-first prompting, and a three-lane guardrail before merge."
The failure state is unchanged from 2024: the LLM drafts against a schema it hasn't seen, invents a column name, the SQL compiles because dbt didn't parse it, the query silently returns the wrong number, and a dashboard ships with 3% row drift for a week. The fix is not "don't use the LLM" — it's "configure repo context + comment-first prompts + dbt parse precommit."
The productivity delta is measurable. Senior teams that have adopted the pattern report 2–3× PR throughput on analytics-engineering work, ~50% reduction in scaffolding time for new Airflow DAGs, and near-instant "explain this legacy CTE" turnaround during incident response.
The interview signal that separates senior from mid is the ability to name failure modes by name — hallucinated columns, wrong SQL dialect, outdated Airflow operator API, drift between .cursorrules and the current dbt convention — not the ability to demo a slick completion.
What interviewers actually listen for.
Do you name .cursorrules and comment-first prompting in the first sentence when asked how you use an LLM? — senior signal.
Do you say "I never accept a completion I haven't run through dbt parse locally"? — required answer.
Do you push back on "just use Cursor Composer for everything" with the review-discipline argument? — senior signal.
Do you name the three-lane guardrail (compile, lint, human review) as your merge invariant? — senior signal.
Do you describe your LLM workflow as "pair programming with a very fast but occasionally lying junior" rather than as "autocomplete"? — required answer.
Worked example — the four-axis LLM-editor comparison for a dbt + Airflow shop
Detailed explanation. The single most useful artifact for an LLM-workflow interview is a memorised 4×4 comparison table. Every senior conversation about "which editor / which model / which agentic depth" converges on this table within the first ten minutes; having it in your head is what separates a fluent answer from a stumbling one. Walk through building the table for a hypothetical analytics-engineering team maintaining a dbt monorepo (~300 models) plus ~40 Airflow DAGs.
Repo shape.analytics-eng/ monorepo with dbt_project.yml, models/{staging,intermediate,marts}/, macros/, analyses/, snapshots/; sibling dags/ folder with 40 Airflow DAGs.
Team. 6 analytics engineers + 2 senior data engineers; ~15 PRs/day; ~4 new dbt models/week.
Constraints. No prod DB credentials in dev; dbt runs against dev_<user> schemas; Snowflake target; Airflow 2.9.
Goal. Ship correct, idiomatic SQL and DAGs 2× faster than the pre-LLM baseline without shipping subtly-wrong SQL.
Question. Build the four-axis LLM-editor comparison for this team and pick the tool + workflow each engineer type should default to.
Input.
Editor / Mode
Repo context
Inline latency
Agentic depth
Guardrail cost
Cursor inline
.cursorrules + open files
200–400 ms
none (caret only)
low
Cursor Composer
.cursorrules + @Codebase
3–10 s
multi-file edit
medium
Copilot inline
current file + recents
150–300 ms
none
low
Copilot Chat
@workspace + @github
2–5 s
single-file suggest
medium
Codex CLI / Aider
full repo (agent-selected)
5–30 s
shell + edit + run
high
Code.
# .cursorrules (top of repo)# This file is the single source of truth for repo prompts.# Every Cursor completion sees this preamble.You are pair-programming with a senior analytics engineer on the`analytics-eng/` dbt + Airflow monorepo.Repo conventions:-dbt profile:snowflake_prod / snowflake_dev-Model layers:staging → intermediate → marts-Naming:stg_<source>__<table>, int_<domain>__<verb>, dim_/fct_-Every model MUST have a schema.yml entry with description + tests-Prefer CTEs over subqueries; one CTE per logical step-SQL dialect:Snowflake (use QUALIFY, ILIKE, DATE_TRUNC, etc.)-Never invent column names; ask the user to paste the schemaif the ref() target is not visible in context-When authoring DAGs, use TaskFlow API (@dag / @task decorators)and Airflow >= 2.9 operator namesGuardrails you MUST respect:-Every SQL suggestion must compile with `dbt parse` (no invalidJinja, no unresolved refs)-Every DAG suggestion must import from `airflow.decorators`, not`airflow.operators.python_operator` (deprecated)-Never write to `prod` targets in generated codeWhen you don't know, say so and ask for the schema.
Step-by-step explanation.
The .cursorrules file at the repo root is the single most important config for a Cursor deployment. Every completion — inline and Composer — sees this preamble injected into the prompt. Getting the tone right ("You are pair-programming with a senior…") sets the model's register; getting the conventions right ("stg___") stops it from inventing names.
The naming section is load-bearing: without it, the LLM defaults to arbitrary snake_case model names ("customer_orders_summary") that violate the team's convention. With it, the LLM proposes mart_customers__orders_summary on the first attempt.
The "SQL dialect: Snowflake" line is what stops the model from generating Postgres-only functions (::TEXT, now()) in a Snowflake repo. Missing this line is one of the most common causes of "the SQL compiled but returned wrong results" bugs.
The guardrail section is the second-line defense: even a well-prompted LLM occasionally drifts, and telling it to respect dbt parse and the current Airflow API teaches it to prefer the correct patterns.
The "when you don't know, say so" instruction turns silent hallucination into loud "please paste the schema for dim_customers." This single line is worth an hour of review time per week.
Output.
Engineer type
Default editor
Default mode
When to escalate
Junior analytics engineer
Cursor inline
comment-first ghost text
Copilot Chat for "explain this"
Senior analytics engineer
Cursor inline + Composer
inline for lines, Composer for multi-file
Codex CLI for repo-wide refactor
Senior data engineer
Cursor + Codex CLI
Composer for DAG edits, CLI for audits
Copilot Chat for legacy explain
On-call engineer (incident)
Copilot Chat + Cursor
Chat for "explain," Cursor for hotfix
Codex CLI only in read-only mode
Rule of thumb. Pick the editor + mode based on (context width × latency budget × agentic depth × review effort). Inline for ghost-text speed, Chat for reasoning, Composer for multi-file edits, CLI for overnight jobs. The .cursorrules file is the config that ties them all together.
Worked example — what senior interviewers actually probe on LLM workflow
Detailed explanation. The senior data-engineering LLM-workflow interview in 2026 has a predictable structure: the interviewer opens with an ambiguous question ("walk me through how you use an AI assistant day-to-day"), then progressively narrows to test whether you know the failure modes. The candidates who name the guardrails in sentence one score highest; the candidates who describe "I use Copilot for autocomplete" score lowest. Walk through the interview grading rubric.
Ambiguous opener. "How do you use AI assistants in your day-to-day SQL / dbt work?" — invites you to name the pattern.
Follow-up 1. "What's in your .cursorrules file?" — probes repo context.
Follow-up 2. "How do you keep it from hallucinating column names?" — probes hallucination-defense pattern.
Follow-up 3. "How do you review LLM-generated Airflow DAGs?" — probes review discipline.
Follow-up 4. "Tell me about a time an LLM shipped subtly-wrong SQL." — probes incident literacy.
Question. Draft a 5-minute senior LLM-workflow answer that covers all four axes without waiting to be asked.
Input.
Interview signal
Weak answer
Senior answer
Named editor + mode
"I use Copilot"
"Cursor inline for ghost text, Composer for multi-file, Copilot Chat for explains"
Repo context
"it just works"
".cursorrules at repo root pinning dbt conventions + SQL dialect"
Hallucination defense
"I read the output"
"comment-first prompts + dbt parse precommit + schema.yml as truth"
Review discipline
"code review catches it"
"three-lane guardrail: dbt compile, sqlfluff lint, human PR read"
Incident literacy
"hasn't happened"
"yes — invented column, dbt parse missed it because Jinja was valid, sqlmesh diff caught it in CI"
Code.
Senior LLM-workflow answer template (5 minutes)
================================================
Minute 1 — name the workflow up front
"I use Cursor as my primary editor with a versioned `.cursorrules`
file at the repo root, GitHub Copilot for inline ghost text when
Cursor's small model lags, and Copilot Chat + Codex CLI for
multi-file refactors and audits. Comment-first prompting is the
single most important discipline."
Minute 2 — repo context and `.cursorrules`
"The `.cursorrules` file pins the SQL dialect (Snowflake), the
dbt naming conventions (stg_/int_/dim_/fct_), the model-layer
hierarchy, and the guardrail: `dbt parse` must pass before any
suggestion is accepted. Every completion sees this preamble.
This alone cuts hallucination rate by ~4×."
Minute 3 — comment-first prompting
"Before I write SQL, I write a comment describing what the SQL
returns: `-- returns customers with 3+ orders in last 90d,
grouped by tier`. Then I let the ghost text complete. The
comment is a contract; if the completion drifts from it, I
discard it. Same discipline for dbt models: docstring first,
SQL second."
Minute 4 — guardrails before merge
"Three lanes. Compile lane: `dbt parse` + `dbt compile` in
precommit. Lint lane: `sqlfluff` + `sqlmesh diff` to catch
semantic drift. Review lane: a human reads every line and runs
at least one EXPLAIN. Nothing ships without all three."
Minute 5 — incident story
"Last quarter Copilot completed a JOIN against a column named
`customer_key` that didn't exist — the schema had `customer_id`.
The Jinja compiled fine, so `dbt parse` passed. `sqlmesh diff`
caught it because the affected model's row count changed by
0.3%, flagged as a warning. Fix: added `dbt run --select ...
--defer` to the precommit for changed models. Root cause: the
context window was stale."
Step-by-step explanation.
Minute 1 is the framing minute. Naming the editor and the discipline immediately — "Cursor + .cursorrules + comment-first" — signals you have a system, not a habit. Weak candidates say "I use Copilot" and stop; senior candidates open with the workflow.
Minute 2 addresses the repo-context axis. Naming the .cursorrules file, its contents (dialect, naming, layer hierarchy), and its effect on hallucination rate ("~4×") shows you've measured what the config does.
Minute 3 addresses prompt hygiene. The comment-first pattern is the single most transferable discipline; naming it explicitly shows you understand that the LLM's output quality is a function of the input's clarity.
Minute 4 covers guardrails. Naming three specific tools (dbt parse, sqlfluff, human review) beats naming "CI" as a black box. The "nothing ships without all three" language shows the discipline is non-negotiable.
Minute 5 is the incident-literacy probe. Every senior data engineer has an LLM-related incident story; telling it well — root cause, detection, fix — is more valuable than pretending it never happened.
Output.
Grading criterion
Weak score
Senior score
Names editor + mode
rare
mandatory
Names .cursorrules
rare
required
Names comment-first
rare
senior signal
Names 3-lane guardrail
rare
senior signal
Tells incident story
rare
senior signal
Rule of thumb. The senior LLM-workflow answer is a 5-minute monologue that covers repo context, prompt hygiene, guardrails, and a concrete incident without waiting for the follow-ups. Rehearse it once; deploy it every time.
Worked example — the "pick the tool" decision tree
Detailed explanation. Given a new task, the senior architect runs a 4-question decision tree in their head. Codifying the tree makes the interview answer reproducible: any interviewer can hand you a task and you can walk the tree out loud. Walk through the tree with three canonical tasks: adding a column to a dbt staging model, scaffolding a new Airflow DAG, and doing a repo-wide refactor of a deprecated macro.
Q1. Does the task touch one file or many? → one = inline; many = Composer / CLI.
Q2. Is the change local and mechanical, or does it need reasoning? → local = inline ghost text; reasoning = Chat / Composer.
Q3. Do you need to run shell / SQL / pytest as part of the task? → yes = Codex CLI / Aider; no = editor-only.
Q4. Is the output going to a PR immediately? → yes = full 3-lane guardrail; no (scratchpad) = lint only.
Question. Walk the decision tree for the three tasks and record the tool + mode each ends up with.
Task 1 — add a column to a staging model. Q1 = one, Q2 = mechanical → inline ghost text is enough. The three-lane guardrail runs on the PR because Q4 = yes.
Task 2 — scaffold a new S3 → Snowflake DAG with a pytest fixture. Q1 = 2–3, Q2 = reasoning (which operator? which sensor? what retry policy?), Q3 = yes (pytest run needed) → Cursor Composer or Copilot Chat, with the human running pytest locally before push.
Task 3 — refactor a deprecated dbt macro across 40 models. Q1 = 40+, Q3 = yes (need dbt parse + dbt run --select) → Codex CLI / Aider / Composer. The agentic mode reads and edits all 40 files; the human reviews the diff and runs the compile step.
Q4 (PR vs scratchpad) determines the guardrail intensity. PR-bound work gets the full 3-lane; scratchpad exploration gets sqlfluff only. Never skip the compile step for PR-bound work — this is the most common source of "the LLM's SQL passed CI but broke prod."
The tree scales: any additional constraint (secrets, cross-repo edits, data-quality tests) adds another guardrail lane, never subtracts one. The rule is that the guardrail depth is a function of the risk, not the tool.
Output.
Task
Tool
Mode
Guardrail
Add column to stg_orders
Cursor inline
comment-first
3-lane
Scaffold new DAG
Cursor Composer / Chat
conversational
3-lane
Refactor macro × 40 models
Codex CLI / Aider
agentic
3-lane
Rule of thumb. The four-question decision tree fits on a sticky note. Practice walking it end-to-end so any interviewer can hand you a task and get a tool + mode + guardrail combo in under 60 seconds.
Senior interview question on LLM-editor selection
A senior interviewer often opens with: "You inherit an analytics-engineering team of six that hand-types every dbt model and Airflow DAG. Adoption of Cursor + Copilot has been informal — some engineers use it constantly, some not at all, and PR quality is uneven. Walk me through the 30-day plan to standardise the LLM workflow, the .cursorrules file you'd ship on day one, the guardrails you'd add to CI, and the metrics you'd track to prove the workflow works."
Solution Using a versioned .cursorrules, a three-lane guardrail, and PR-throughput / defect-rate metrics
# .cursorrules — day one; versioned in the repo rootYou are pair-programming with a senior analytics engineer on the`analytics-eng/` dbt + Airflow monorepo. Snowflake target,Airflow 2.9 TaskFlow API, dbt 1.8.Repo conventions:-Model layers:staging (stg_) → intermediate (int_) → marts (dim_/fct_/mart_)-Every model has schema.yml with description + tests-Prefer CTEs; one CTE per logical step; ORDER BY only at the end-SQL dialect: Snowflake (QUALIFY / ILIKE / DATE_TRUNC / ::casting)-dbt macros live in macros/; import from dbt_utils and dbt_expectations-DAGs use @dag / @task decorators; sensors from airflow.sensors.*Never do:-Never invent a column name — ask for schema.yml if unclear-Never use `airflow.operators.python_operator` (deprecated)-Never write to `prod` targets in generated code-Never suggest a completion that fails `dbt parse`When you don't know, say so and ask.
# .pre-commit-config.yaml — three-lane guardrailrepos:# Lane 1 — compile-repo:localhooks:-id:dbt-parsename:dbt parseentry:dbt parse --profiles-dir profileslanguage:systempass_filenames:false-id:dbt-compile-changedname:dbt compile (changed)entry:bash -c 'dbt compile --select state:modified --defer --state ./manifest'language:systempass_filenames:false# Lane 2 — lint-repo:https://github.com/sqlfluff/sqlfluffrev:3.2.0hooks:-id:sqlfluff-lintargs:[--dialect,snowflake]-id:sqlfluff-fixargs:[--dialect,snowflake,--force]# Lane 3 — model-level tests (CI, not precommit — too slow)# runs in GitHub Actions: dbt build --select state:modified+
# metrics.py — track the workflow impact over 30 days
importpandasaspdMETRICS=pd.DataFrame({"metric":["PRs merged per engineer per week","mean PR lifetime (open → merged)","dbt models added per week","post-merge defects per 100 PRs","sqlfluff violations per PR","`.cursorrules` violations per PR",],"baseline_wk0":[4.2,"2.1 d",1.5,8,12,"N/A"],"target_wk4":[8.0,"0.9 d",3.5,6,4,"< 1"],})# Alert if defect rate rises above baseline for two consecutive weeks
# (LLM-driven regressions typically show up here first)
Step-by-step trace.
Step
Before (informal adoption)
After (standardised workflow)
.cursorrules
absent
versioned; ~40 lines at repo root
Comment-first prompting
ad-hoc
required by convention
Precommit compile
none
dbt parse + dbt compile --select state:modified
Precommit lint
inconsistent
sqlfluff with snowflake dialect
PR review discipline
uneven
template requires "ran EXPLAIN?" checkbox
Metrics
none
PR throughput, mean lifetime, defect rate tracked weekly
Onboarding
tribal knowledge
30-min recorded pair session per engineer
After the 30-day rollout, PR throughput doubles (~4/wk → ~8/wk per engineer), mean PR lifetime drops from ~2 days to under a day, and the defect rate holds steady or falls (the guardrails catch what the increased volume would otherwise ship). The two engineers who were skeptical of the LLM workflow become the loudest advocates once the .cursorrules file removes the "it hallucinated my column name" objection.
Output:
Metric
Baseline (week 0)
After 30 days (target)
PRs merged / engineer / week
4.2
8.0
Mean PR lifetime
2.1 d
0.9 d
dbt models added / week
1.5
3.5
Post-merge defects per 100 PRs
8
6
sqlfluff violations per PR
12
4
.cursorrules violations per PR
N/A
< 1
Why this works — concept by concept:
.cursorrules as source of truth — versioning the LLM's repo prompt in the repo itself makes it a first-class deliverable, reviewable in PRs, evolvable with the codebase. This is the single most impactful config change.
Comment-first prompting — the natural-language contract you write before the SQL is what pins ghost text to intent. Skipping it forces the LLM to guess from surrounding context, which is where most hallucinations enter.
Three-lane precommit — dbt parse catches unresolved refs; dbt compile catches invalid Jinja; sqlfluff catches style drift. Running all three locally before push turns "the LLM shipped bad SQL to CI" into "the LLM's suggestion never made it out of the editor."
Deferred compile — dbt compile --defer --state ./manifest compiles only the models whose upstream state has actually changed, keeping the precommit fast even in a 300-model repo.
Cost — one .cursorrules file (~40 lines), one .pre-commit-config.yaml (~30 lines), a 30-min onboarding session per engineer, and weekly metric tracking. The payback is 2× PR throughput and stable defect rate. Compared to hand-typing everything, this is O(1) config for O(N) engineer-hours saved per week.
SQL Topic — sql SQL problems for LLM-assisted authoring practice
2. Cursor rules, .cursorrules, and repo-scoped context for SQL / dbt
The .cursorrules file is the single artifact that decides whether Cursor writes idiomatic repo-SQL or generic textbook SQL
The mental model in one line: cursor rules for sql are a versioned, repo-scoped preamble that every Cursor completion (inline and Composer) sees before it drafts a single character — a well-crafted .cursorrules pins the SQL dialect, the dbt naming convention, the model-layer hierarchy, the do-nots, and the guardrail expectations, and moves Cursor from "generic SQL assistant" to "member of the analytics-engineering team who remembers the conventions from month one." The single most measured lever in a Cursor deployment is the quality of this file; teams that neglect it complain about hallucination rates, teams that maintain it treat Cursor as a productivity multiplier.
The four axes of a good .cursorrules file.
Voice + role. Open with "You are pair-programming with a senior analytics engineer on the <repo> monorepo." This sets the LLM's register — it stops writing tutorial-grade SQL and starts writing the kind of terse, CTE-heavy SQL a senior would ship. Skip this line and every completion reads like a Stack Overflow answer.
Conventions. Explicit rules for naming (stg_, int_, dim_, fct_), model layers (staging → intermediate → marts), style (CTE per step, ORDER BY at the end, QUALIFY over subqueries for window filters), and SQL dialect (Snowflake / BigQuery / Postgres). Every convention you omit is one the LLM will guess.
Do-nots. Explicit anti-patterns: "never use airflow.operators.python_operator (deprecated)," "never invent a column name — ask for schema.yml," "never write to prod targets." Do-nots are more effective than do's for constraining generation.
Guardrail expectations. "Every SQL suggestion must compile with dbt parse" — telling the LLM what the reviewer will check makes it self-check before proposing. This is prompt-engineering leverage.
The context window — what Cursor actually sees at completion time.
.cursorrules. Always injected. Bounded (~1–2k tokens is comfortable; over ~5k crowds out useful file context).
Currently open file. Full text (up to a per-model window budget).
Recently opened files. Truncated; more recent = more weight.
@Codebase symbol. In Cursor Chat / Composer, @Codebase performs a vector search over the repo and injects the top-N matched files. Cost: a few seconds of retrieval latency; benefit: the LLM now sees the actual schema.yml for the model you're editing.
@Docs symbol. Injects external docs the user has linked (dbt docs, Airflow docs, Snowflake syntax). Use for API-drift-prone tasks (Airflow operator names, dbt macro signatures).
Failure modes senior engineers pre-empt.
Stale rules. The team renames dim_customer → dim_customers; the .cursorrules still references the old name; every completion drifts. Fix: PR-review the .cursorrules file whenever a convention changes; treat it as part of the schema.
Over-prompting. A 500-line .cursorrules file eats the context window and leaves no room for the actual file the user is editing. Fix: keep it under ~1500 tokens; put verbose examples in docs/ and let @Docs pull them.
Invalidated context. The user opens 15 files, the context window fills with the earliest, and the current-file completion has stale scope. Fix: close unused tabs; use Cursor: Restart Cursor to reset context if suggestions drift.
Silent drift. The LLM starts suggesting completions that violate .cursorrules and no one notices because the completions still compile. Fix: add a per-PR sqlfluff rule for repo-idiom (sqlfluff doesn't do this natively; roll a custom dbt-checkpoint or pre-commit-dbt rule that greps for prohibited patterns).
Common interview probes on .cursorrules.
"What's the first thing you'd put in a .cursorrules file?" — the voice + role line ("You are pair-programming with a senior…").
"How long should a .cursorrules file be?" — 1–2 kilobytes; under ~1500 tokens; anything longer crowds out context.
"How do you keep the LLM from inventing column names?" — comment-first prompt + @Codebase or @Docs injection of schema.yml + the do-not rule.
"How often do you update .cursorrules?" — every time a repo convention changes; reviewed in PRs like any other config.
Worked example — a production-grade .cursorrules for a dbt + Snowflake repo
Detailed explanation. Build a .cursorrules file for a real analytics-engineering repo: dbt 1.8, Snowflake, ~300 models across staging/intermediate/marts, dbt_utils + dbt_expectations installed, a macros/ folder with 20 custom macros, Airflow 2.9 for orchestration. Every clause has a reason; walk through them one by one.
Model shape. stg → int → mart layers; every model has a schema.yml entry.
SQL style. CTE-first, QUALIFY over subqueries, snake_case, ORDER BY only when needed.
dbt idioms.ref() over hardcoded schema, source() for raw tables, dbt_utils.pivot over hand-rolled CASE.
Airflow idioms. TaskFlow API, @dag/@task decorators, sensors from airflow.sensors.*, retries on network operators.
Question. Write a production-grade .cursorrules for this repo, staying under 1500 tokens, and explain each section's purpose.
Input.
Section
Purpose
Token budget
Role + voice
set the model's register
~50
SQL dialect + repo shape
ground the completions
~200
Naming conventions
pin identifier shape
~200
Style rules
pin structural patterns
~200
dbt + Airflow idioms
pin idiomatic API usage
~300
Do-nots
explicit anti-patterns
~200
Guardrail expectations
teach the LLM to self-check
~150
Code.
# .cursorrules
You are pair-programming with a senior analytics engineer on the
`analytics-eng/` monorepo. This repo runs on Snowflake, dbt 1.8,
and Airflow 2.9. You write terse, review-ready SQL and Python
that respects the following conventions.
## Repo shape-`dbt_project/` — dbt project root
-`models/staging/` — one folder per source system; models named `stg_<source>__<table>.sql` -`models/intermediate/` — models named `int_<domain>__<verb>.sql` -`models/marts/` — models named `dim_<entity>.sql` (dimensions) or `fct_<event>.sql` (facts)
-`macros/` — custom macros, one per file; snake_case
-`snapshots/` — dbt snapshots for slowly-changing dimensions
-`dags/` — Airflow DAGs, one file per DAG; DAG id matches filename
-`tests/` — pytest for DAGs
## SQL style- SQL dialect: Snowflake. Use QUALIFY, ILIKE, DATE_TRUNC, :: casting, TRY_CAST.
- Prefer CTEs over subqueries; one CTE per logical step.
- Name CTEs after their purpose (`customers_with_orders`, `latest_status_per_order`).
- ORDER BY only in the final SELECT and only when required by downstream.
- Never SELECT * in a mart; always list columns explicitly.
- Use `{{ ref('...') }}` for dbt models and `{{ source('...') }}` for raw tables.
- For pivots, prefer `dbt_utils.pivot`; for testing, prefer `dbt_expectations`.
## dbt idioms- Every model has a `schema.yml` entry with `description`, `columns:`, and at least one `unique` or `not_null` test on the primary key.
- Use `{{ config(materialized='incremental', unique_key='id', on_schema_change='sync_all_columns') }}` for incremental models.
- Never hardcode a schema name in SQL; always route through `{{ ref() }}` / `{{ source() }}`.
- Macros go in `macros/`; document with a `{% docs %}` block.
## Airflow idioms- Use TaskFlow API: `@dag`, `@task`, `@task_group`.
- Sensors: `S3KeySensor`, `SnowflakeSensor`, `TimeDeltaSensor` — never poll in a `PythonOperator`.
- Every operator that touches the network gets `retries=3, retry_delay=timedelta(minutes=5)`.
- Never import from `airflow.operators.python_operator` (deprecated in 2.8; removed in 3.0).
- Every DAG has a docstring at the top: purpose, schedule, owner.
## Do-nots- Never invent a column name. If the ref() target is not in context, ask for the schema.yml or pause and request the schema.
- Never write to a `prod_` target in generated code.
- Never suggest a completion that would fail `dbt parse`.
- Never use `now()` in Snowflake — use `current_timestamp()`.
- Never omit `ORDER BY` inside a window function that needs deterministic ordering.
## Guardrail expectations
Every completion you propose will be checked by:
1.`dbt parse` (unresolved refs fail here)
2.`dbt compile --select state:modified` (invalid Jinja fails here)
3.`sqlfluff lint --dialect snowflake` (style fails here)
4. Human PR read (semantic drift fails here)
If you're not confident a suggestion passes all four, ask the user for clarification instead of guessing.
Step-by-step explanation.
The role line ("pair-programming with a senior analytics engineer") sets the register. Without it, Cursor writes tutorial-grade SQL with unnecessary comments and beginner idioms. With it, the completions are terse and CTE-heavy.
The repo shape section tells Cursor where files live. This is what stops it from generating a new dbt model in dbt_project/dbt_project/models/ (a common mistake when the LLM guesses the folder structure).
The SQL style section pins structural choices: CTE-first, QUALIFY over subqueries, no SELECT * in marts. Every rule here is one the LLM would otherwise guess randomly.
The dbt idioms section is the highest-leverage: it teaches the LLM the exact dbt patterns your team uses (incremental config, schema.yml convention, dbt_utils.pivot). Skipping this section gets you generic dbt that doesn't fit.
The do-nots section is more effective than the do's because "never invent a column name" is a specific defense against the single most-cited hallucination failure mode. Naming failure modes by name in the rules file trains the LLM to avoid them.
The guardrail expectations section closes the loop: the LLM knows what tools will check its output and can self-check against those tools. This is what turns "propose anything" into "propose only what will pass CI."
Output.
Section
Before rules (hallucination rate)
After rules
Column name invention
~1/10 completions
~1/50 completions
Wrong SQL dialect
~1/8 completions
~1/100 completions
Non-idiomatic dbt
~1/4 completions
~1/25 completions
Deprecated Airflow API
~1/6 completions
~1/50 completions
Missing schema.yml entry
~1/3 new models
~1/15 new models
Rule of thumb. Write the .cursorrules file in the same voice you'd use in a good CONTRIBUTING.md. Sections in order: role, repo shape, SQL style, dbt idioms, Airflow idioms, do-nots, guardrails. Keep it under ~1500 tokens. Review it in PRs. Rewrite it when a convention changes.
Worked example — using @Codebase and @Docs for context-window control
Detailed explanation. Even a perfect .cursorrules file cannot make the LLM know what columns dim_customers has. For that, Cursor exposes two symbols: @Codebase (vector-search the repo and inject matching files) and @Docs (inject external docs the user has linked). Using them well is what turns Cursor Chat / Composer from "generic assistant" into "assistant with your schema in memory." Walk through a canonical workflow.
Task. Write an incremental dbt model int_orders__enriched.sql that joins stg_orders with dim_customers on customer_id.
Problem. Cursor Composer doesn't know the columns on either table.
Solution.@Codebase stg_orders.sql schema.yml + @Codebase dim_customers.sql schema.yml injects both models' definitions and their schema.yml entries.
Question. Draft the Cursor Composer prompt for this task, using @Codebase to pull in the two upstream models and their schemas.
Input.
Symbol
Injects
Cost
@Codebase <term>
vector-searched files matching term
2–5 s retrieval
@Codebase <exact-file>
that file's full contents
0.5 s
@Docs <linked-doc>
user-added docs (dbt, Airflow, Snowflake)
1–3 s
@Files
manually-selected file list
~0 s
@Web
live web search (Cursor Chat only)
3–10 s
Code.
# Cursor Composer prompt
Author `models/intermediate/int_orders__enriched.sql`.
Context:
@Codebase stg_orders schema.yml
@Codebase dim_customers schema.yml
@Docs dbt-incremental-materialization
Requirements:
- Incremental materialization on Snowflake, unique_key='id'
- Join stg_orders (o) with dim_customers (c) on o.customer_id = c.customer_id
- Add computed columns: customer_lifetime_value (SUM of o.total_cents per customer),
order_recency_days (DATE_DIFF from o.order_date to current_date)
- Filter to orders in the last 730 days
- Add schema.yml entry with tests: unique on id, not_null on id, relationships
to dim_customers on customer_id
- Follow the repo's stg_/int_/mart_ naming; QUALIFY over subqueries where possible
Step-by-step explanation.
The prompt opens with the task — one sentence describing what file to author and what it does. Cursor Composer works best when the task is scoped concretely.
The Context: block uses three symbol injections. @Codebase stg_orders schema.yml performs a vector search and pulls in stg_orders.sql and schema.yml; the LLM now sees the actual columns. @Codebase dim_customers schema.yml does the same for the dimension. @Docs dbt-incremental-materialization pulls in the dbt docs page for incremental materialization.
The requirements section pins the SQL shape: incremental config, join keys, computed columns, filters, tests. Each requirement is a specific instruction; skip any of them and the LLM guesses.
The final line ("follow the repo's naming") is a hint that the .cursorrules file will also inject. Redundant reminders in the prompt help when the context window is deep.
The output is a first-draft .sql file plus a proposed schema.yml entry, both of which the engineer reviews, runs dbt parse on, and either commits or iterates.
Output.
Prompt element
Without @Codebase
With @Codebase
Column-name accuracy
~60% (guessed from stem names)
~99% (read from schema.yml)
Join-key accuracy
~70%
~99%
dbt config correctness
~80% (docs in preamble)
~99% (docs re-injected)
First-draft compilable rate
~40%
~90%
Reviewer iterations
3–4
1
Rule of thumb. For any Cursor Composer task that touches upstream models, always inject @Codebase <model> schema.yml for each upstream. For API-drift-prone tasks (Airflow operators, dbt macro signatures), always inject @Docs. The two-second retrieval cost pays back in first-draft accuracy.
Worked example — context-window management for a 300-model monorepo
Detailed explanation. In a 300-model dbt monorepo, the context window fills quickly. If the user has 15 files open in tabs, Cursor prioritises the current file plus recent tabs; older tabs get truncated or dropped. This means the LLM's "memory" of the codebase is much smaller than the codebase itself. Walk through the discipline that keeps the context window tight.
Symptom. Cursor suggests a completion that references dim_customer (old name) instead of dim_customers (new name), even though the .cursorrules mentions the new name.
Cause. An old tab with dim_customer references is still open; it's competing with the .cursorrules for context.
Fix. Close unused tabs; use Cursor: Reindex Codebase after schema changes; use @Files to explicitly scope context.
Question. Design a context-window hygiene routine for a 300-model repo and quantify the impact on suggestion quality.
Input.
Practice
Cost
Benefit
Close unused tabs
~10 s
keep current-file context weighted
Reindex after schema change
~30 s
fresh codebase vector index
Use @Files for scoped context
~0 s
explicit control, no drift
Reset context weekly
~5 s
flush stale conversation history
Update .cursorrules on rename
~1 min per rename
prevent old name leakage
Code.
# context-window-hygiene.sh — a weekly reset routine for Cursor# 1. Close all files in Cursor (Cmd-K Cmd-W)# 2. Reindex codebase (Cmd-Shift-P → "Cursor: Reindex Codebase")# 3. Verify .cursorrules is currentcat .cursorrules | wc-c# target < 6000 chars (~ 1500 tokens)# 4. Verify docs/ folder is current (Cursor @Docs pulls from here)ls-la docs/ | head-20# 5. Grep for any stale model references in .cursorrules or docs/grep-rn"dim_customer\b" .cursorrules docs/ ||echo"clean"# 6. Optional: purge the Cursor cache for a full reset# (macOS) rm -rf ~/Library/Application\ Support/Cursor/User/globalStorage# (linux) rm -rf ~/.config/Cursor/User/globalStorage
The core discipline is that the context window is a scarce resource. Every open tab, every conversation-history turn, every retrieved file eats into the budget. The default Cursor context is ~8k–16k tokens for chat, less for inline. Above the ceiling, the earliest content gets truncated silently.
Closing unused tabs is the cheapest and highest-leverage practice. The LLM weights recent tabs; if a stale tab is open, it can leak old names into completions. Cmd-K Cmd-W (close all) is a fine end-of-session habit.
Reindexing after schema changes rebuilds Cursor's codebase vector index. Without it, @Codebase returns stale search results — you'll get the old model names for a day or two after a rename.
The .cursor/settings.json file (project-scoped) lets you pin what's always in context. autoInclude: [".cursorrules", "dbt_project.yml", "profiles.yml"] guarantees these three files are always visible to Cursor, regardless of what's open.
The grep -rn "old_name\b" .cursorrules docs/ check catches the "renamed a model but forgot to update the rules" case. Adding this to a weekly hygiene routine turns silent context drift into a caught defect.
Output.
Symptom
Without hygiene
With hygiene
Stale-name completions per week
5–10
0–1
Cursor Composer first-draft accuracy
~70%
~90%
Time spent debugging "why did it suggest that?"
~2 h/week
~0 h/week
Context-window overflow errors
occasional
rare
Weekly hygiene cost
0 min (skipped)
~5 min
Rule of thumb. Treat the Cursor context window as a scarce resource. Close unused tabs, reindex after schema changes, pin .cursorrules + dbt_project.yml + profiles.yml via autoInclude, and grep for stale names weekly. Five minutes of hygiene prevents ten hours of "why did the LLM suggest that?" debugging.
Senior interview question on Cursor rules
A senior interviewer might ask: "Your analytics team just adopted Cursor. Some engineers love it, some complain it hallucinates constantly. You've been asked to standardise the setup. Walk me through the .cursorrules file you'd ship, the repo-context-injection patterns you'd teach the team, and how you'd measure the impact over 30 days."
Solution Using a versioned .cursorrules, @Codebase/@Docs injection patterns, and a hallucination-rate metric
# .cursorrules — production draft, ~1300 tokens
You are pair-programming with a senior analytics engineer on the
`analytics-eng/` monorepo. Snowflake 8.x, dbt 1.8, Airflow 2.9.
Terse, review-ready SQL and Python; respect these conventions.
## Repo shape- dbt_project/ — models/{staging,intermediate,marts}, macros/, snapshots/, seeds/
- dags/ — one Airflow DAG per file; DAG id matches filename
- tests/ — pytest for DAGs
- profiles.yml — snowflake_prod / snowflake_dev
## Naming- Staging: stg_<source>__<table>.sql
- Intermediate: int_<domain>__<verb>.sql
- Marts: dim_<entity>.sql | fct_<event>.sql | mart_<aggregate>.sql
- Every model has a schema.yml entry with `description`, `columns:`, and PK tests.
## SQL style- Snowflake dialect (QUALIFY, ILIKE, DATE_TRUNC, ::casting, TRY_CAST).
- CTE-first; one CTE per logical step; named after purpose.
- ORDER BY only in the final SELECT and only when needed downstream.
- Never SELECT * in a mart.
- Prefer `{{ ref() }}` and `{{ source() }}` over hardcoded schema.
- Prefer `dbt_utils.pivot` over hand-rolled CASE for pivots.
## dbt idioms- Incremental: `{{ config(materialized='incremental', unique_key='id', on_schema_change='sync_all_columns') }}`- Snapshots: strategy='timestamp' with `updated_at` column.
- Macros: one per file, snake_case, documented with `{% docs %}` block.
## Airflow idioms- TaskFlow API: `@dag`, `@task`, `@task_group`.
- Sensors: `S3KeySensor`, `SnowflakeSensor` — never poll in `PythonOperator`.
- Network operators: `retries=3, retry_delay=timedelta(minutes=5)`.
- Never `airflow.operators.python_operator` (deprecated).
- Every DAG has a top-of-file docstring: purpose, schedule, owner.
## Do-nots- Never invent a column name. Ask for the schema.yml if unclear.
- Never write to `prod_` targets in generated code.
- Never suggest a completion that would fail `dbt parse`.
- Never use `now()` on Snowflake — use `current_timestamp()`.
- Never omit `ORDER BY` inside a window function that needs deterministic ordering.
## Guardrails you must respect
Every completion is checked by:
1.`dbt parse` (unresolved refs fail)
2.`dbt compile --select state:modified` (invalid Jinja fails)
3.`sqlfluff lint --dialect snowflake` (style fails)
4. Human PR read (semantic drift fails)
If a suggestion might fail any of these, ask for clarification instead.
# hallucination_rate.py — weekly metric
# Run: python hallucination_rate.py --pr-window 7d
importsubprocess,re,sysfromcollectionsimportdefaultdict# 1. List PRs merged in the last 7 days
prs=subprocess.check_output(["gh","pr","list","--state=merged","--search","merged:>=2026-07-23","--json","number,files"]).decode()hallucination_flags=defaultdict(int)total_prs=0# 2. For each PR, check the diff against `.cursorrules` do-nots
DO_NOTS=[r"\bfrom airflow\.operators\.python_operator\b",r"\bnow\s*\(\s*\)",# Snowflake: use current_timestamp()
r"SELECT\s+\*.*FROM\s+\{\{\s*ref\(",# SELECT * in a mart
]importjsonforprinjson.loads(prs):total_prs+=1diff=subprocess.check_output(["gh","pr","diff",str(pr["number"])]).decode()forpatinDO_NOTS:ifre.search(pat,diff,re.I):hallucination_flags[pat]+=1# 3. Report
print(f"PRs scanned: {total_prs}")forpat,cntinhallucination_flags.items():print(f"{cnt:3d} PRs violated: {pat}")print(f"Overall hallucination rate: {sum(hallucination_flags.values())/total_prs:.1%}")
Step-by-step trace.
Step
Value
Reasoning
.cursorrules size
~1300 tokens
under the 1500-token ceiling; leaves room for file context
mandatory for Composer tasks touching upstream models
injects schema.yml so LLM sees real columns
@Docs usage
mandatory for Airflow / dbt API tasks
prevents deprecated-API suggestions
Hygiene routine
weekly reindex + tab cleanup
keeps context fresh
Metric
hallucination rate per merged PR
weekly grep against do-not patterns
After the 30-day rollout, hallucination-flag rate (do-not-pattern violations in merged PRs) drops from ~15% baseline to ~2%. The two engineers who complained about Cursor "constantly making things up" become the loudest advocates once the .cursorrules file removes the "it invented a column name" objection. PR throughput doubles; defect rate holds steady.
Output:
Metric
Baseline (week 0)
After 30 days
PRs / engineer / week
4.2
8.0
Do-not violations / merged PR
15%
2%
dbt parse failures / PR
8%
1%
Deprecated Airflow API sightings
5/wk
0/wk
Engineer NPS on Cursor
4/10
8/10
Why this works — concept by concept:
Versioned .cursorrules — putting the LLM's repo prompt in the repo makes it a first-class deliverable that evolves with the codebase and is reviewed in PRs. The alternative — every engineer keeping their own personal preamble — reintroduces the variance the rules file exists to eliminate.
Comment-first prompting — the natural-language contract you write before the SQL is what pins ghost text to intent. Without it, the LLM guesses from surrounding code, which is where most hallucinations enter.
@Codebase schema injection — vector-search injection of schema.yml for the upstream models the current model references is what stops column-name hallucination in Composer tasks. Two-second retrieval cost; near-eliminates the failure mode.
Do-not clauses — telling the LLM what not to do is more effective than telling it what to do. "Never use airflow.operators.python_operator" is more effective than "prefer TaskFlow API" because the negation is unambiguous.
Cost — one .cursorrules file (~1300 tokens), a weekly 5-minute hygiene routine, and a weekly hallucination-rate metric script. The payback is 2× PR throughput and a ~7× drop in do-not violations. Compared to letting each engineer figure out their own prompt discipline, this is O(1) config for O(N × week) engineer-hours saved.
SQL Topic — sql-generation SQL-generation problems for prompt / context practice
Comment-first prompting is the single lever that turns Copilot's ghost text from lucky to reliable
The mental model in one line: github copilot sql is at its best when you write a comment describing what the SQL should return, let Copilot draft the ghost text, review it against the comment, and either accept or iterate — the comment is a contract between you and the model, and treating it as a contract (not a hint) is what separates a productive Copilot workflow from a lottery of hopeful autocompletes. Every senior data engineer who ships Copilot-authored SQL day-in day-out uses the same three-line pattern: write the intent comment, wait for the ghost text, verify against the comment before accepting.
The four modes of GitHub Copilot, and when to use each.
Inline ghost text. Sub-300-ms completions at the caret. Best for line-by-line SQL, small edits, filling in SELECT column lists, completing dbt Jinja blocks. Comment-first prompt is the discipline.
Copilot Chat. Conversational; @workspace, @github, @vscode symbols. Best for "explain this query," "generate a test for this dbt model," "why does this DAG fail on the sensor step?"
Copilot Workspace. Multi-file suggestions bundled as PRs. Best for "add pagination to all three list endpoints" or "migrate this dbt project from schema.yml v1 to v2." Longer latency; multi-file scope.
Copilot CLI.gh copilot suggest / gh copilot explain in the terminal. Best for shell one-liners, git recipes, "how do I run dbt with defer?" from the command line.
The comment-first pattern — the load-bearing Copilot discipline.
What it is. Before writing SQL, write a comment describing what the SQL should return: -- returns customers with 3+ orders in the last 90 days, grouped by tier. Then hit Enter and let the ghost text fill.
Why it works. Copilot's model heavily weights nearby comments as prompt intent. A specific comment produces specific SQL; a vague comment produces vague SQL.
How to write good comments. Name the output shape (columns + grain), the filter conditions, the aggregation, and the ordering. "returns customers with 3+ orders in the last 90 days, grouped by tier, ordered by total_spend desc" is a five-clause contract.
How to iterate. If the ghost text drifts, edit the comment to be more specific — never start typing SQL and expect the LLM to catch up. The comment leads; the SQL follows.
Failure modes senior engineers pre-empt.
Hallucinated columns. Copilot invents customer_key when the schema has customer_id. Fix: pull the source table into an open tab; add a -- source: dim_customers has (customer_id, tier, signup_date) comment above the completion; or use Cursor's @Codebase if you're in Cursor.
Wrong SQL dialect. Copilot writes Postgres ::TEXT in a Snowflake file; writes CURRENT_TIMESTAMP in a MySQL file. Fix: add a -- Snowflake header comment at the top of the file; put the dialect in .cursorrules (Cursor) or the workspace instructions (Copilot Chat).
Non-idiomatic dbt. Copilot suggests hardcoded schema references instead of {{ ref() }}. Fix: keep an example ref()-based query at the top of the file as an in-context template.
Deprecated API. Copilot suggests airflow.operators.python_operator (deprecated). Fix: pin the Airflow version in a header comment; use Cursor's .cursorrules do-not clause.
Common interview probes on Copilot.
"How do you keep Copilot from hallucinating column names?" — comment-first prompt + open the source table's schema.yml in a sibling tab.
"How do you make Copilot suggest the right SQL dialect?" — file-header dialect comment + .cursorrules in Cursor.
"How do you review Copilot-generated dbt models?" — dbt parse precommit + human read against the schema.yml.
"When would you prefer Cursor Composer over Copilot inline?" — multi-file edits, repo-context-heavy tasks (e.g. renaming a model across 40 references).
Worked example — authoring a window-function query with Copilot inline
Detailed explanation. The canonical Copilot inline task: rank customers by lifetime spend, keep the top-N per tier, return their most-recent order. The senior data engineer's move is comment-first: write a five-clause comment, hit Enter, let the ghost text fill, verify, accept or iterate.
Task. Rank customers by lifetime spend within tier; keep top 100 per tier; return their most recent order.
Discipline. Comment describes the exact output shape; ghost text drafts the SQL; human verifies against the comment.
Dialect. Snowflake — file header states so.
Question. Author the query using Copilot inline with a comment-first prompt, and show the ghost-text output.
top 100 customers per tier by lifetime spend, with most-recent order
Ordering
tier ASC, rank ASC
Code.
-- Snowflake-- returns top 100 customers per tier by lifetime spend,-- with their most recent order (order_date, total_cents).-- Grain: one row per customer.-- Filters: status IN ('paid', 'shipped') only.WITHcustomer_lifetimeAS(SELECTo.customer_id,c.tier,SUM(o.total_cents)ASlifetime_cents,MAX(o.order_date)ASmost_recent_order_dateFROM{{ref('stg_orders')}}oJOIN{{ref('dim_customers')}}cUSING(customer_id)WHEREo.statusIN('paid','shipped')GROUPBYo.customer_id,c.tier),most_recent_rowAS(SELECTo.customer_id,o.order_dateASmost_recent_order_date,o.total_centsASmost_recent_order_centsFROM{{ref('stg_orders')}}oQUALIFYROW_NUMBER()OVER(PARTITIONBYo.customer_idORDERBYo.order_dateDESC)=1),rankedAS(SELECTcl.customer_id,cl.tier,cl.lifetime_cents,mr.most_recent_order_date,mr.most_recent_order_cents,ROW_NUMBER()OVER(PARTITIONBYcl.tierORDERBYcl.lifetime_centsDESC)AStier_rankFROMcustomer_lifetimeclJOINmost_recent_rowmrUSING(customer_id))SELECTcustomer_id,tier,lifetime_cents,most_recent_order_date,most_recent_order_cents,tier_rankFROMrankedWHEREtier_rank<=100ORDERBYtierASC,tier_rankASC
Step-by-step explanation.
The five-line comment header is the prompt contract. It names the output (top 100 customers per tier), the grain (one row per customer), and the filter (status IN paid/shipped). Copilot's ghost text draft was ~95% of this final; the engineer accepted with two small tweaks.
The customer_lifetime CTE aggregates spend per customer per tier. Copilot suggested this shape immediately from the "lifetime spend" phrasing in the comment.
The most_recent_row CTE uses QUALIFY ROW_NUMBER() = 1 — the Snowflake idiom for "keep the most recent row per key." Copilot suggested QUALIFY because the file-header comment stated the dialect. Without that comment, Copilot defaults to a subquery / LEFT JOIN pattern.
The ranked CTE joins the two prior CTEs and computes tier_rank. Copilot correctly used PARTITION BY tier + ORDER BY lifetime_cents DESC — a standard senior pattern.
The final SELECT filters tier_rank <= 100 and orders by tier then rank. The ORDER BY at the end matches the .cursorrules "ORDER BY only in final SELECT" rule.
Output.
customer_id
tier
lifetime_cents
most_recent_order_date
most_recent_order_cents
tier_rank
4021
Platinum
1,240,000
2026-07-28
42,000
1
8801
Platinum
980,000
2026-07-30
33,000
2
2312
Gold
540,000
2026-07-24
28,000
1
7793
Silver
98,000
2026-07-15
5,500
1
Rule of thumb. Write the comment first with five clauses (output columns, grain, filter, aggregation, ordering). Wait for the ghost text. Verify against the comment. Accept or iterate on the comment — never on the SQL. This is the single most transferable Copilot discipline.
Worked example — dbt macro completion with Jinja + dbt_utils
Detailed explanation. Copilot is uneven on Jinja by default because Jinja is a small fraction of most training data. Anchoring it with a well-formed opening — {% macro ... %} on the first line, dbt_utils import in a comment above — makes it produce correct Jinja + SQL macros most of the time. Walk through authoring a dbt_utils.pivot-based macro.
Task. A macro that pivots a wide order_events table into a per-event-type column set (event_type_paid, event_type_shipped, etc.).
The opening {#- ... -#} block is a docstring in Jinja. It tells Copilot what the macro does, what arguments it takes, and what shape it returns. Without it, Copilot's suggestions drift.
The {% macro pivot_order_events(model, group_by) %} signature is what the engineer typed; Copilot then filled the body. The macro name is descriptive, the args are named — both give Copilot enough context.
The dbt_utils.get_column_values(...) line dynamically pulls the distinct event types from the input model. Copilot suggested this pattern from the dbt_utils import comment; without the import hint, it would have generated a static list.
The dbt_utils.pivot(...) call is the actual pivot. then_value=1, else_value=0, agg='count' computes a per-type count. prefix='event_type_' gives clean column names.
The final GROUP BY {{ group_by }} closes the aggregation. Copilot correctly used the macro arg — a common failure mode is to hardcode order_id here; the docstring prevented it.
Output.
order_id
event_type_paid
event_type_shipped
event_type_returned
101
1
1
0
102
1
0
0
103
1
1
1
104
1
1
0
Rule of thumb. For Jinja / dbt macros, anchor Copilot with a full docstring + macro signature + relevant import hints in comments. The docstring is the prompt; the macro body follows.
Worked example — comment-first pattern for a complex CTE chain
Detailed explanation. A gnarly CTE chain — sessionise events, compute per-session revenue, attribute the session to a marketing channel — is the kind of task that Copilot handles poorly without a good comment header and handles very well with one. Walk through the discipline.
Task. Sessionise page_events (30-min inactivity threshold), attribute the first-touch channel per session, join to orders to compute revenue per session.
Prompt technique. Multi-clause comment describing each CTE by name.
Question. Author the query using a per-CTE comment header, and show the resulting SQL.
-- Snowflake-- Pipeline:-- 1. sessions = sessionise page_events with 30-min inactivity threshold-- 2. session_attribution = first-touch utm_source per session-- 3. session_revenue = orders that happened during the session, summed-- 4. output = one row per session with attribution + revenueWITHsessionedAS(SELECTuser_id,event_ts,url,utm_source,SUM(CASEWHENDATEDIFF(minute,LAG(event_ts)OVER(PARTITIONBYuser_idORDERBYevent_ts),event_ts)>30THEN1ELSE0END)OVER(PARTITIONBYuser_idORDERBYevent_ts)ASsession_idxFROM{{ref('stg_page_events')}}),sessionsAS(SELECTuser_id,session_idx,MIN(event_ts)ASsession_start,MAX(event_ts)ASsession_endFROMsessionedGROUPBYuser_id,session_idx),session_attributionAS(SELECTs.user_id,s.session_idx,s.session_start,s.session_end,FIRST_VALUE(sd.utm_source)OVER(PARTITIONBYs.user_id,s.session_idxORDERBYsd.event_ts)ASfirst_touch_channelFROMsessionssJOINsessionedsdONsd.user_id=s.user_idANDsd.session_idx=s.session_idxQUALIFYROW_NUMBER()OVER(PARTITIONBYs.user_id,s.session_idxORDERBYsd.event_ts)=1),session_revenueAS(SELECTsa.user_id,sa.session_idx,sa.first_touch_channel,SUM(o.total_cents)ASsession_revenue_centsFROMsession_attributionsaLEFTJOIN{{ref('stg_orders')}}oONo.customer_id=sa.user_idANDo.order_tsBETWEENsa.session_startANDsa.session_endGROUPBY1,2,3)SELECT*FROMsession_revenueORDERBYsession_revenue_centsDESCNULLSLAST
Step-by-step explanation.
The header comment lists four CTEs by name and one-line purpose. Copilot uses this as a scaffold — each CTE below matches a comment line. Without the header, the LLM would produce a single monolithic subquery.
sessioned computes a running session index via a windowed CASE that increments whenever the gap between consecutive events exceeds 30 minutes. Copilot suggested this pattern from the "sessionise" verb in the comment.
sessions aggregates events into session bounds (start/end timestamps). This is the standard session-collapse pattern.
session_attribution uses QUALIFY ROW_NUMBER() = 1 to pick the first event per session and pulls its utm_source. Copilot correctly used QUALIFY for the top-N-per-key idiom.
session_revenue joins to orders on the time window and sums revenue. The LEFT JOIN keeps sessions that generated no revenue.
Output.
user_id
session_idx
first_touch_channel
session_revenue_cents
4021
3
google_ads
42,000
8801
1
organic
33,000
2312
5
facebook_ads
28,000
7793
2
direct
NULL
Rule of thumb. For complex CTE chains, write a top-of-file comment listing every CTE by name and one-line purpose before letting Copilot draft. The comment is the outline; the SQL is the fill. This produces cleaner, more auditable SQL than any single-shot prompt.
Senior interview question on Copilot patterns
A senior interviewer might ask: "You're leading an analytics team on a Snowflake + dbt stack. GitHub Copilot has been available to the team for six months. Half the engineers use it well; half have shipped subtly-wrong SQL because Copilot invented column names. Walk me through the Copilot discipline you'd standardise — the comment-first pattern, the file-header conventions, the hallucination defenses — and how you'd measure the impact on shipped-defect rate."
Solution Using comment-first prompting, file-header dialect pins, and a per-PR hallucination-flag script
-- COPILOT WORKFLOW TEMPLATE (paste at top of new .sql files)-- Dialect: Snowflake (dbt 1.8, snowflake_prod / snowflake_dev)-- Source: {{ ref('stg_orders') }}, {{ ref('dim_customers') }}-- Grain: one row per customer per tier-- Filters: status IN ('paid','shipped'); order_date >= dateadd(day, -730, current_date)-- Output: customer_id, tier, lifetime_cents, tier_rank-- Step 1 — comment describes what the SQL returns-- Step 2 — wait for Copilot ghost text-- Step 3 — verify against the comment-- Step 4 — accept, or edit the comment (never the SQL) to iterate
# copilot_hallucination_scan.py — precommit hook
# Scans staged .sql / .py files for known hallucination patterns.
# Runs on `git commit`; blocks commit if any pattern matches.
importsubprocess,re,sysSTAGED=subprocess.check_output(["git","diff","--cached","--name-only"]).decode().splitlines()PATTERNS={# Wrong-dialect timestamp on Snowflake
r"\bnow\s*\(\s*\)":"wrong dialect (use current_timestamp() on Snowflake)",# Deprecated Airflow operator
r"\bfrom airflow\.operators\.python_operator\b":"deprecated Airflow API",# Hardcoded schema reference in a dbt model
r"\bFROM\s+[a-z_]+\.[a-z_]+\s+":"hardcoded schema — use {{ ref() }}",# SELECT * in a mart
r"SELECT\s+\*\s+FROM\s+\{\{\s*ref\(":"SELECT * in a mart — list columns",}violations=[]forfninSTAGED:ifnotfn.endswith((".sql",".py")):continuetry:text=open(fn).read()exceptFileNotFoundError:continueforpat,msginPATTERNS.items():forminre.finditer(pat,text,re.I|re.M):line=text[:m.start()].count("\n")+1violations.append(f"{fn}:{line}{msg} → {m.group(0)!r}")ifviolations:print("Copilot hallucination scan flagged the following:")print("\n".join(violations))print("\nEdit and re-commit, or run `git commit --no-verify` if intentional.")sys.exit(1)
# .pre-commit-config.yaml — add the scanrepos:-repo:localhooks:-id:copilot-hallucination-scanname:copilot hallucination scanentry:python scripts/copilot_hallucination_scan.pylanguage:systempass_filenames:falsestages:[commit]
catches wrong-dialect + deprecated API + hardcoded schema
Human PR review
required
verifies output against the comment contract
Weekly hallucination-rate metric
tracked
catches convention drift over time
After the 30-day rollout of the comment-first template + hallucination scan, the shipped-defect rate on Copilot-authored SQL drops from ~15% to ~2%. The two engineers who "shipped subtly-wrong SQL" become the loudest advocates of the template because the precommit scan now catches what code review used to miss.
Output:
Metric
Baseline (week 0)
After 30 days
PRs / engineer / week
3.5
7.0
Copilot-authored SQL rows
40%
65%
Shipped defects / 100 PRs
15
2
Wrong-dialect flags / week
4
0
Hardcoded-schema flags / week
6
0
Why this works — concept by concept:
Comment-first prompting — the header comment is a contract between the engineer's intent and Copilot's draft. Writing it first anchors the ghost text; iterating on it (not on the SQL) keeps the discipline pure.
File-header dialect pin — a single-line -- Snowflake at the top of every SQL file eliminates the wrong-dialect failure mode. This is the smallest possible fix for the highest-frequency hallucination.
Hallucination-flag precommit scan — a lightweight regex-based scanner catches the top-4 do-not patterns (wrong dialect, deprecated Airflow API, hardcoded schema, SELECT * in mart) before commit. Blocks the commit; forces a fix.
Human review against the comment contract — the PR reviewer's first check is "does the SQL do what the comment says?" This turns review from a "read the code" chore into a "verify the contract" audit, which is faster and higher-signal.
Cost — one file-header template (paste-at-top-of-new-files), one 30-line hallucination scan, one PR-template checkbox. The payback is a ~7× drop in shipped-defect rate and 2× PR throughput. Compared to letting Copilot roam free, this is O(1) config for O(N × week) engineer-hours saved.
SQL Topic — joins/sql SQL joins and window-function problems for Copilot practice
4. Airflow DAG authoring with LLM assistants — task groups, sensors, tests
Docstring-first DAG scaffolding is what turns Copilot / Cursor from "generic Python assistant" into "senior data engineer who knows the TaskFlow API"
The mental model in one line: airflow dag copilot authoring works best when you write a top-of-file docstring stating the DAG's purpose, schedule, owner, retry policy, and task-list — the LLM then scaffolds the @dag and @task decorators, proposes sensors and task groups, and generates the pytest fixture — turning a 60-minute DAG-drafting task into a 10-minute review-and-tweak task while keeping the reviewer in charge of every architectural choice. Every senior data engineer who ships LLM-authored DAGs uses the same pattern: docstring first, decorators second, business logic third, tests fourth.
The four axes of a good LLM-authored DAG.
Docstring shape. Every DAG has a top-of-file docstring: purpose, schedule (@daily, cron), owner, upstream dependencies, retry policy, SLAs. The docstring is the prompt for everything below it.
TaskFlow API.@dag, @task, @task_group decorators from airflow.decorators. The LLM must never generate airflow.operators.python_operator (deprecated in 2.8, removed in 3.0). .cursorrules do-not clause; Copilot header pin.
Sensors. Explicit sensor operators (S3KeySensor, SnowflakeSensor, TimeDeltaSensor) — never poll in a PythonOperator. The LLM proposes; the reviewer verifies against the upstream reality.
Tests. Every DAG has a pytest fixture that (a) imports the DAG, (b) asserts task list, (c) asserts dependency graph, (d) mocks external operators. The LLM scaffolds all four; the reviewer verifies.
The docstring-first pattern — the load-bearing DAG-authoring discipline.
What it is. Write a 5–8-line module docstring at the top of the file describing the DAG. Then let the LLM scaffold the DAG below.
Why it works. The docstring is the prompt intent. A specific docstring produces a specific DAG; a vague docstring produces a vague DAG.
How to write good docstrings. Name the purpose (what data flows where), the schedule (cron / preset), the owner (team + on-call), the upstream (what triggers this DAG), the retry policy (how many, how long between).
How to iterate. If the LLM's DAG drifts, edit the docstring — never the code below. The docstring leads; the DAG follows.
Failure modes senior engineers pre-empt.
Deprecated operator API. LLM suggests PythonOperator instead of @task, or S3ListOperator instead of S3ListOperator from the correct provider path. Fix: .cursorrules do-not clause pinning Airflow 2.9 TaskFlow API; file-header -- Airflow 2.9 comment for Copilot.
Missing retries on network operators. LLM writes a SnowflakeOperator with no retries= kwarg. Fix: docstring names the retry policy; .cursorrules requires retries=3 on network operators.
Sensor polling in PythonOperator. LLM writes while not s3.head_object(...) in a task. Fix: docstring names the sensor explicitly ("wait for s3://bucket/{{ ds }}/_SUCCESS with S3KeySensor").
No pytest fixture. LLM ships a DAG without any test. Fix: PR template requires "pytest for DAG"; .cursorrules do-not clause "never ship a DAG without a pytest fixture."
Common interview probes on LLM-authored DAGs.
"How do you keep the LLM from generating a deprecated Airflow API?" — .cursorrules do-not clause + file-header dialect pin + @Docs airflow-2.9 injection.
"How do you review LLM-generated DAGs?" — pytest-first, then human read against the docstring contract.
"How do you make the LLM propose the right sensor?" — name the sensor in the docstring; put the sensor's expected key format in a comment.
"How do you test LLM-generated DAGs?" — pytest that imports the DAG, asserts task list, asserts dependency graph, mocks external operators.
Worked example — a Snowflake → S3 DAG with a sensor and a task group
Detailed explanation. The canonical LLM-assisted DAG task: ingest a daily partition from Snowflake into S3, gated by an upstream _SUCCESS marker sensor, with a task group for the extract → transform → load steps, then run a downstream dbt run --select step. Walk through the docstring-first scaffold.
Purpose. Daily incremental extract from Snowflake to S3, then trigger downstream dbt.
Sensor.S3KeySensor on s3://raw/orders/{{ ds }}/_SUCCESS.
Task group.extract_load containing three tasks: extract_orders, write_to_s3, verify_s3_row_count.
Downstream.dbt_run task using the DbtRunOperator from the community provider.
Question. Write the module docstring; let Cursor Composer scaffold the DAG; verify against the docstring.
Input.
Component
Value
DAG id
orders_ingest_daily
Schedule
@daily (00:15 UTC)
Sensor
S3KeySensor on _SUCCESS marker
Task group
extract_load
Downstream
dbt run --select fct_orders+
Code.
"""
DAG: orders_ingest_daily
Purpose:
Daily incremental extract of orders from Snowflake to S3,
then trigger downstream dbt run on fct_orders and its children.
Schedule:
@daily (00:15 UTC).
Upstream:
S3 marker at s3://raw/orders/{{ ds }}/_SUCCESS
(published by the upstream ingest DAG).
Owner:
data-platform-team; on-call: pagerduty analytics-oncall.
Retry policy:
retries=3, retry_delay=timedelta(minutes=5), retry_exponential_backoff=True.
SLA:
Complete within 90 minutes of scheduled start; page if breached.
Tasks:
1. wait_for_marker — S3KeySensor on the _SUCCESS marker.
2. extract_load — task_group:
a. extract_orders — Snowflake -> DataFrame.
b. write_to_s3 — DataFrame -> parquet at s3://curated/orders/{{ ds }}/.
c. verify_s3_row_count — sanity check: row count matches source.
3. dbt_run — dbt run --select fct_orders+.
"""from__future__importannotationsfromdatetimeimportdatetime,timedeltafromairflow.decoratorsimportdag,task,task_groupfromairflow.sensors.filesystemimportFileSensorfromairflow.providers.amazon.aws.sensors.s3importS3KeySensorfromairflow.providers.snowflake.hooks.snowflakeimportSnowflakeHookfromairflow.providers.amazon.aws.hooks.s3importS3HookDEFAULT_ARGS={"owner":"data-platform-team","retries":3,"retry_delay":timedelta(minutes=5),"retry_exponential_backoff":True,"email_on_failure":False,"sla":timedelta(minutes=90),}@dag(dag_id="orders_ingest_daily",default_args=DEFAULT_ARGS,schedule="15 0 * * *",# 00:15 UTC daily
start_date=datetime(2026,7,1),catchup=False,tags=["orders","ingest","snowflake","s3"],doc_md=__doc__,)deforders_ingest_daily():wait_for_marker=S3KeySensor(task_id="wait_for_marker",bucket_key="orders/{{ ds }}/_SUCCESS",bucket_name="raw",aws_conn_id="aws_default",poke_interval=60,timeout=60*60*2,# 2 hours
mode="reschedule",# release worker slot while poking
)@task_group(group_id="extract_load")defextract_load(execution_date:str):@taskdefextract_orders(execution_date:str)->dict:"""Read the ingested partition from raw.orders into a Snowflake stage."""hook=SnowflakeHook(snowflake_conn_id="snowflake_prod")sql=f"""
SELECT COUNT(*) AS n
FROM raw.orders
WHERE order_date = '{execution_date}'"""row=hook.get_first(sql)return{"n_source":int(row[0])}@taskdefwrite_to_s3(execution_date:str,extract_meta:dict)->dict:"""Unload the partition to S3 as parquet."""hook=SnowflakeHook(snowflake_conn_id="snowflake_prod")hook.run(f"""
COPY INTO @s3_curated/orders/{execution_date}/
FROM (SELECT * FROM raw.orders WHERE order_date = '{execution_date}')
FILE_FORMAT = (TYPE = PARQUET)
HEADER = TRUE
""")return{"n_source":extract_meta["n_source"]}@taskdefverify_s3_row_count(execution_date:str,write_meta:dict)->None:"""Assert the parquet row count matches the source row count."""s3=S3Hook(aws_conn_id="aws_default")keys=s3.list_keys(bucket_name="curated",prefix=f"orders/{execution_date}/")n_written=sum(_parquet_row_count(s3,"curated",k)forkinkeys)ifn_written!=write_meta["n_source"]:raiseValueError(f"row count mismatch: source={write_meta['n_source']}"f"written={n_written}")meta=extract_orders(execution_date)write_meta=write_to_s3(execution_date,meta)verify_s3_row_count(execution_date,write_meta)@taskdefdbt_run(execution_date:str)->None:"""dbt run --select fct_orders+ --vars {{'run_date': execution_date}}."""importsubprocesssubprocess.check_call(["dbt","run","--select","fct_orders+","--vars",f"{{run_date: '{execution_date}'}}","--profiles-dir","/opt/dbt/profiles",])wait_for_marker>>extract_load(execution_date="{{ ds }}")>>dbt_run(execution_date="{{ ds }}")def_parquet_row_count(s3,bucket:str,key:str)->int:"""Read parquet footer for the row count without downloading data."""importio,pyarrow.parquetaspqobj=s3.get_conn().get_object(Bucket=bucket,Key=key)footer=pq.ParquetFile(io.BytesIO(obj["Body"].read()))returnfooter.metadata.num_rowsorders_ingest_daily=orders_ingest_daily()
Step-by-step explanation.
The 20-line module docstring is the LLM's prompt. It names the DAG id, purpose, schedule, upstream, owner, retry policy, SLA, and — crucially — a task list. Cursor Composer scaffolded 95% of the code from this docstring; the engineer accepted with two small tweaks (adding mode="reschedule" to the sensor and adding the parquet footer row-count helper).
The imports are all from airflow.decorators and airflow.providers.* — no airflow.operators.python_operator. The .cursorrules do-not clause enforced this.
DEFAULT_ARGS centralises the retry policy and SLA. The LLM proposed this shape from the docstring's retry-policy line.
The @dag(...) decorator is TaskFlow API. doc_md=__doc__ renders the module docstring in the Airflow UI — a small but high-value discipline.
wait_for_marker uses S3KeySensor with mode="reschedule" (release the worker slot between pokes). The engineer added mode because the LLM's first draft used the default mode="poke" — a subtle but important production hygiene.
extract_load is a @task_group with three tasks: extract_orders, write_to_s3, verify_s3_row_count. The task group frames them in the UI as one collapsed node.
dbt_run shells out to the dbt CLI. The subprocess call is deliberately explicit — the community DbtRunOperator has API drift between provider versions, so calling the CLI directly is more portable.
The dependency graph at the bottom (wait_for_marker >> extract_load(...) >> dbt_run(...)) is the DAG's shape. Explicit >> operators are more readable than task_id strings.
Output.
Task
Type
Retries
SLA
wait_for_marker
S3KeySensor
3 (via default_args)
90 min
extract_load.extract_orders
@task
3
inherited
extract_load.write_to_s3
@task
3
inherited
extract_load.verify_s3_row_count
@task
3
inherited
dbt_run
@task (subprocess)
3
inherited
Rule of thumb. Write the module docstring first (purpose, schedule, upstream, owner, retry, SLA, task list). Let the LLM scaffold from it. Verify each task against the docstring line. Add production-hygiene tweaks (mode="reschedule" on sensors, explicit retry_exponential_backoff) manually — the LLM misses these by default.
Worked example — LLM-generated pytest for the DAG
Detailed explanation. Every LLM-authored DAG must ship with a pytest fixture that (a) imports the DAG file, (b) asserts the DAG loads without errors, (c) asserts the task list matches expectation, (d) asserts the dependency graph is correct, (e) mocks external operators for smoke-test. The LLM scaffolds all five; the reviewer verifies.
Prompt. "Generate pytest for the orders_ingest_daily DAG covering import, task list, dependency graph, and operator mocks."
Convention. Every test file matches test_<dag_name>.py; sits in tests/dags/.
Question. Author the pytest fixture using Copilot Chat; show the resulting test file.
Input.
Assertion
Value
DAG imports without error
required
Task count
5 (marker + 3 in group + dbt_run)
Task ids
expected set
Dependency graph
wait_for_marker → extract_load → dbt_run
SLA
90 minutes
Code.
# tests/dags/test_orders_ingest_daily.py
from__future__importannotationsfromdatetimeimporttimedeltaimportpytestfromairflow.modelsimportDagBag@pytest.fixture(scope="module")defdagbag()->DagBag:"""Load all DAGs; fail fast if any DAG file has a parse error."""returnDagBag(dag_folder="dags/",include_examples=False)deftest_orders_ingest_daily_imports(dagbag):"""DAG file must load cleanly (no syntax / import errors)."""assert"orders_ingest_daily"indagbag.dag_ids,dagbag.import_errorsassertdagbag.import_errors=={}deftest_orders_ingest_daily_task_list(dagbag):"""Task list must match the docstring."""dag=dagbag.get_dag("orders_ingest_daily")expected_ids={"wait_for_marker","extract_load.extract_orders","extract_load.write_to_s3","extract_load.verify_s3_row_count","dbt_run",}actual_ids={t.task_idfortindag.tasks}assertactual_ids==expected_ids,(f"unexpected task set — actual: {actual_ids}, expected: {expected_ids}")deftest_orders_ingest_daily_dependencies(dagbag):"""Dependency graph must be wait_for_marker -> extract_load -> dbt_run."""dag=dagbag.get_dag("orders_ingest_daily")marker=dag.get_task("wait_for_marker")extract_first=dag.get_task("extract_load.extract_orders")dbt=dag.get_task("dbt_run")# Marker feeds into the first task of the task group
assertextract_first.task_idin{t.task_idfortinmarker.downstream_list},"wait_for_marker should feed extract_load.extract_orders"# verify_s3_row_count feeds into dbt_run
verify=dag.get_task("extract_load.verify_s3_row_count")assertdbt.task_idin{t.task_idfortinverify.downstream_list},"verify_s3_row_count should feed dbt_run"deftest_orders_ingest_daily_sla(dagbag):"""SLA must be 90 minutes on every task."""dag=dagbag.get_dag("orders_ingest_daily")fortindag.tasks:assertt.sla==timedelta(minutes=90),(f"task {t.task_id!r} SLA {t.sla} != 90 min")deftest_orders_ingest_daily_retries(dagbag):"""Retries must be 3 on every task (via default_args)."""dag=dagbag.get_dag("orders_ingest_daily")fortindag.tasks:assertt.retries==3,f"task {t.task_id!r} retries {t.retries} != 3"deftest_orders_ingest_daily_sensor_reschedule_mode(dagbag):"""The S3KeySensor must run in 'reschedule' mode (release worker slot)."""dag=dagbag.get_dag("orders_ingest_daily")sensor=dag.get_task("wait_for_marker")assertsensor.mode=="reschedule",(f"sensor mode {sensor.mode!r} != 'reschedule' — worker slots not released")
Step-by-step explanation.
The dagbag fixture loads every DAG under dags/ and surfaces import errors. include_examples=False skips the Airflow-shipped examples.
test_orders_ingest_daily_imports fails fast on any parse error. This is the single highest-value test — most LLM-generated DAG bugs are import-time errors.
test_orders_ingest_daily_task_list asserts the exact set of task ids. If the LLM later "improves" the DAG and adds a task, this test surfaces the change in review.
test_orders_ingest_daily_dependencies walks the dependency graph and asserts the two critical edges: marker → first extract task, verify → dbt_run. A three-node DAG generates three edges; this covers the two that matter.
test_orders_ingest_daily_sla and test_orders_ingest_daily_retries verify the production-hygiene defaults from default_args. These often regress silently when someone edits the DAG.
test_orders_ingest_daily_sensor_reschedule_mode catches the "sensor is still in poke mode and hogging worker slots" regression — a specific production incident that this test prevents from recurring.
Output.
Test
Assertion
Fails when
imports
DAG loads cleanly
any syntax / import error
task_list
exact 5 task ids
task added / removed / renamed
dependencies
marker → extract → dbt_run edges
dep graph rewired
sla
90 min on every task
default_args edited
retries
3 on every task
default_args edited
sensor_reschedule_mode
S3KeySensor uses reschedule
someone reverts to poke
Rule of thumb. Every LLM-authored DAG ships with a pytest fixture that asserts (a) import, (b) task list, (c) dependency graph, (d) production-hygiene defaults (SLA, retries, sensor mode). The LLM drafts the fixture from the docstring; the reviewer verifies.
Worked example — the LLM-proposed sensor pattern
Detailed explanation. A common LLM anti-pattern is to write a polling loop inside a PythonOperator instead of using an explicit sensor. The docstring-first pattern preempts this by naming the sensor. Walk through the fix.
Anti-pattern.while True: if s3.head_object(...): break; time.sleep(60) inside a task.
Fix. Explicit S3KeySensor operator; docstring names the sensor.
Discipline..cursorrules do-not clause: "never poll in a PythonOperator — use the appropriate Sensor."
Question. Show the anti-pattern, the fix, and the .cursorrules clause that preempts it.
Input.
Aspect
Anti-pattern
Correct pattern
Task type
@task with while loop
S3KeySensor operator
Worker slot
held throughout poll
released with mode='reschedule'
Timeout
manual
operator kwarg
Retries
manual
operator kwarg
UI visibility
opaque
first-class node
Code.
# ANTI-PATTERN — never do this
fromdatetimeimportdatetimeimporttimefromairflow.decoratorsimportdag,task@dag(dag_id="bad_dag",schedule="@daily",start_date=datetime(2026,7,1))defbad_dag():@taskdefwait_for_s3_marker():"""Wait for the _SUCCESS marker (WRONG — polls in a PythonOperator)."""fromairflow.providers.amazon.aws.hooks.s3importS3Hooks3=S3Hook()whileTrue:try:s3.get_key("orders/2026-07-30/_SUCCESS",bucket_name="raw")returnexceptException:time.sleep(60)# holds the worker slot for hours
wait_for_s3_marker()bad_dag=bad_dag()
# CORRECT — use the S3KeySensor
fromdatetimeimportdatetime,timedeltafromairflow.decoratorsimportdagfromairflow.providers.amazon.aws.sensors.s3importS3KeySensor@dag(dag_id="good_dag",schedule="@daily",start_date=datetime(2026,7,1))defgood_dag():S3KeySensor(task_id="wait_for_s3_marker",bucket_key="orders/{{ ds }}/_SUCCESS",bucket_name="raw",aws_conn_id="aws_default",poke_interval=60,timeout=60*60*6,# 6-hour ceiling
mode="reschedule",# releases worker slot between pokes
)good_dag=good_dag()
# .cursorrules — the clause that preempts this## Do-nots- Never poll in a PythonOperator or @task. Use the appropriate Sensor
(S3KeySensor, SnowflakeSensor, TimeDeltaSensor).
- Every Sensor MUST set `mode='reschedule'` unless the sensor is expected
to complete within `poke_interval` seconds — release worker slots.
- Every Sensor MUST set `timeout` explicitly — no infinite pokes.
Step-by-step explanation.
The anti-pattern is subtle because it works — the task waits for the marker and eventually completes. The problem is operational: it holds a worker slot for hours, it doesn't surface in the Airflow UI as a sensor node, and it doesn't participate in the SLA / retry policy the way sensors do.
The correct pattern uses S3KeySensor from airflow.providers.amazon.aws.sensors.s3. mode="reschedule" releases the worker slot between pokes — the scheduler wakes the task every poke_interval seconds without keeping a slot warm.
timeout=60 * 60 * 6 sets a 6-hour ceiling. Without it, a stuck upstream would wedge the task forever. Explicit timeouts are a senior discipline.
poke_interval=60 sets the poll cadence. For an _SUCCESS marker that typically appears within 30 minutes, 60 seconds is a reasonable balance between latency and API load.
The .cursorrules do-not clauses ("never poll in a PythonOperator," "every Sensor MUST set mode='reschedule'") are what preempt the anti-pattern in the LLM's suggestions. Without them, LLMs default to polling loops ~30% of the time.
Output.
Concern
Anti-pattern
Correct pattern
Worker slot
held for hours
released between pokes
UI visibility
one opaque task
first-class sensor node
Retry semantics
inside try/except
operator kwarg
Timeout enforcement
absent
operator kwarg
SLA compatibility
broken (custom loop)
first-class
Rule of thumb. Every wait-for-external-condition step is a Sensor, not a PythonOperator with a loop. .cursorrules do-not clause + explicit mode="reschedule" + explicit timeout= are the three defenses that preempt this LLM failure mode.
Senior interview question on Airflow DAG authoring
A senior interviewer might ask: "You inherit an Airflow deployment where half the DAGs were LLM-authored by junior engineers. Sensors are polling in PythonOperators, retry policies are inconsistent, and there's no pytest coverage. Walk me through the standardisation — the docstring-first template, the .cursorrules do-not clauses, the pytest fixture pattern, and the CI checks that catch regressions."
Solution Using a docstring-first template, TaskFlow-only .cursorrules, and mandatory pytest fixtures
# dags/_template.py — the standard DAG template, versioned in the repo
"""
DAG: <dag_id>
Purpose:
<what data flows where — one sentence>
Schedule:
<@daily / cron>
Upstream:
<what triggers this DAG — sensor keys, external DAG, TimeDeltaSensor>
Owner:
<team>; on-call: <pagerduty rotation>
Retry policy:
retries=3, retry_delay=timedelta(minutes=5), retry_exponential_backoff=True
SLA:
<N> minutes; page if breached
Tasks:
1. <task_id_1> — <purpose>
2. <task_id_2> — <purpose>
..."""from__future__importannotationsfromdatetimeimportdatetime,timedeltafromairflow.decoratorsimportdag,task,task_groupDEFAULT_ARGS={"owner":"<team>","retries":3,"retry_delay":timedelta(minutes=5),"retry_exponential_backoff":True,"email_on_failure":False,"sla":timedelta(minutes=90),}@dag(dag_id="<dag_id>",default_args=DEFAULT_ARGS,schedule="<schedule>",start_date=datetime(2026,7,1),catchup=False,tags=["<domain>"],doc_md=__doc__,)def<dag_id>():pass<dag_id>=<dag_id>()
# .github/workflows/dag-checks.yml — CI guardrailsname:dag-checkson:[pull_request]jobs:dag-parse:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-uses:actions/setup-python@v5with:{python-version:'3.11'}-run:pip install -r requirements.txt-name:dag import testrun:|python -c "from airflow.models import DagBagdb = DagBag('dags/', include_examples=False)if db.import_errors:for f, e in db.import_errors.items():print(f'{f}: {e}')raise SystemExit(1)print(f'loaded {len(db.dag_ids)} dags')"-name:pytest for dagsrun:pytest tests/dags/ -q-name:dag-lint (deprecated APIs)run:|if grep -rn "airflow.operators.python_operator" dags/; thenecho "deprecated API found"; exit 1fiif grep -rn "PythonOperator" dags/ | grep -v test_; thenecho "PythonOperator found — prefer @task"; exit 1fi
# .cursorrules additions for DAG authoring## Airflow do-nots- Never import from airflow.operators.python_operator (deprecated 2.8, removed 3.0).
- Never use PythonOperator — always @task (TaskFlow API).
- Never poll in a @task or PythonOperator — use the appropriate Sensor.
- Every Sensor must set `mode='reschedule'` and explicit `timeout`.
- Every network operator (SnowflakeOperator, S3Copy, etc.) inherits `retries=3` from default_args — never override to a lower value.
- Every DAG file has a module docstring with: purpose, schedule, upstream, owner, retry policy, SLA, task list.
- Every DAG file has a matching tests/dags/test_<dag_id>.py fixture asserting: import, task list, dependency graph, SLA, retries.
After the 30-day standardisation, every new DAG ships from the template, TaskFlow API is universal, sensor polling in PythonOperators falls to zero, and pytest coverage rises from ~30% to ~95%. The two engineers who were skeptical of pytest for DAGs become the loudest advocates once the first regression is caught pre-merge.
Output:
Metric
Baseline (week 0)
After 30 days
DAGs following template
40%
95%
DAGs using TaskFlow API
60%
100%
Sensors in mode='reschedule'
35%
100%
DAGs with pytest coverage
30%
95%
Deprecated-API grep hits
8
0
Post-merge DAG defects / month
6
1
Why this works — concept by concept:
Docstring-first template — the module docstring is the LLM's prompt. Standardising the docstring shape via a template forces the LLM to produce compliant DAGs. Skipping the template forces every author to reinvent the docstring shape, and the LLM's DAGs drift accordingly.
TaskFlow-only .cursorrules — pinning @dag/@task decorators and prohibiting PythonOperator / airflow.operators.python_operator in the rules file (plus enforcing via CI grep) eliminates the deprecated-API regression entirely.
Mandatory pytest fixtures — asserting import, task list, dependency graph, SLA, retries in a per-DAG pytest file surfaces regressions before merge. The tests are cheap to write (LLM scaffolds them) and cheap to run (~10 s per DAG).
Sensor mode = reschedule + explicit timeout — the two config kwargs that turn sensors from "worker-slot-hogging polling loops" into "polite scheduler-managed waits." .cursorrules clause + CI grep enforce.
Cost — one DAG template file, ~30 lines of .cursorrules additions, one CI workflow, one pytest fixture per DAG. The payback is 100% TaskFlow adoption, 100% correct sensor mode, and a ~6× drop in post-merge DAG defects. Compared to letting LLM-authored DAGs land ad-hoc, this is O(1) config for O(N × DAG) engineer-hours saved.
ETL Topic — etl ETL problems for Airflow DAG design
5. Guardrails, review discipline & the SQL-engineer's Copilot workflow
The three-lane guardrail — compile, lint, human review — is what makes LLM-authored SQL safe to ship
The mental model in one line: sql pair programming with an LLM is only safe when every completion passes through three independent lanes before merge — the compile lane (dbt parse + dbt compile) catches unresolved refs and invalid Jinja, the lint lane (sqlfluff + sqlmesh diff) catches style and semantic drift, and the human review lane catches the intent mismatches that neither compile nor lint can see — and skipping any one of the three lanes is what turns a productive Copilot workflow into a source of subtle production defects that only surface days later in dashboards or reconciliation reports. Every senior engineer who has been burned by an LLM's hallucination has responded by hardening one of these three lanes; the ones who haven't been burned yet are lucky, not skilled.
The three lanes of the guardrail — what each catches and what it misses.
Compile lane.dbt parse + dbt compile --select state:modified. Catches: unresolved ref(), invalid Jinja, missing macros, invalid source(), syntax errors. Misses: semantically-wrong SQL that happens to be syntactically valid (invented column names that exist elsewhere in the schema, wrong join keys, off-by-one filters).
Lint lane.sqlfluff lint --dialect snowflake + sqlmesh diff (or dbt-checkpoint). Catches: style drift (SELECT * in marts, missing schema.yml entries, inconsistent CTE naming), semantic drift (a model's row count moved by >5%), deprecated API references. Misses: semantically-wrong SQL that leaves row count unchanged (silently wrong aggregation, subtle join-cardinality bugs).
Human review lane. A person reads the diff, verifies against the docstring / comment contract, runs at least one EXPLAIN, and considers "does this SQL do what the intent says?" Catches: intent-code mismatches, semantic bugs that don't move row counts, novel bugs that no lint rule anticipated. Misses: nothing systematically, but is expensive and inconsistent across reviewers.
The four review disciplines every senior SQL engineer signs up for.
Read every line. LLM-authored SQL that "looks right" is exactly the SQL that ships hallucinations. Read every line as if it were written by an unfamiliar junior — because it effectively was.
Run every query at least once. In a dev / staging environment, against a representative dataset. The compile lane says "the SQL is valid"; running says "the SQL returns what the comment claims."
Verify the EXPLAIN plan. For any query on a mart or dimension table, look at the plan. Hallucinated columns don't show up as missing joins in the plan; they show up as unexpected Cartesian products or full scans.
Verify the row count. If the LLM edited a model, compare the row count before and after in a --defer compile. Row-count drift is the single most sensitive signal for semantic regression.
The daily workflow — the SQL engineer's Copilot day.
Morning. Pull main, run dbt parse locally to seed the state manifest for --defer. Reset Cursor tabs; reindex codebase if any schema change was merged overnight.
Task loop. For each task: write comment / docstring, wait for ghost text, verify, iterate, commit locally (precommit runs compile + lint + hallucination scan), push, open PR.
Review. For every PR (yours or teammates'), read every line, verify against the docstring, run one query, note the EXPLAIN plan, approve or request changes.
End of day. Close all tabs; sync .cursorrules if any convention changed today.
Weekly. Reindex codebase; grep for stale names in .cursorrules; review the hallucination-rate metric; add new failure modes to the do-not clauses.
Common interview probes on guardrails.
"What's your review checklist for LLM-authored SQL?" — read every line, run every query, verify EXPLAIN, check row count.
"How do you catch semantically-wrong-but-syntactically-valid SQL?" — sqlmesh diff (row-count drift) + human review against the docstring.
"How do you handle LLM-authored code in incident review?" — treat it identically to human-authored code; root cause the failure mode (usually stale context or missing do-not clause); patch .cursorrules.
"How do you know your guardrails work?" — the hallucination-rate metric: do-not-pattern violations per merged PR, tracked weekly.
Worked example — the daily SQL engineer's Copilot workflow
Detailed explanation. Codify the day-to-day workflow of a senior analytics engineer who ships ~2 dbt PRs and ~1 Airflow DAG PR per day, all LLM-assisted. Every step has a discipline; walk through them.
Morning setup. ~5 minutes to seed the local dbt state manifest, reset Cursor context, review overnight PRs.
Task loop. ~30–60 minutes per task, four-phase: prompt, iterate, precommit, PR.
Review loop. ~10–15 minutes per teammate PR.
End of day. ~5 minutes to sync .cursorrules and close tabs.
Question. Write the daily workflow as a checklist, with time budgets.
Input.
Phase
Duration
Purpose
Morning setup
5 min
fresh state manifest + fresh Cursor context
Task loop
30–60 min × N tasks
authoring
Review loop
10–15 min × M PRs
verification
End of day
5 min
hygiene
Weekly
30 min
metric review + rules-file update
Code.
#!/usr/bin/env bash# copilot_daily.sh — the SQL engineer's daily rhythmset-euo pipefail
case"${1:-help}"in
morning)# 5-minute morning setup
git checkout main && git pull
dbt deps
dbt parse --profiles-dir profiles
# snapshot the state manifest for --defer during the daycp target/manifest.json ./state/manifest.json
echo"state manifest snapshotted at $(date)"# remind me to reset Cursorecho"→ In Cursor: Cmd-K Cmd-W (close all), Cmd-Shift-P → 'Cursor: Reindex Codebase'";;
task)# start a new task: create a branch, remind me of the disciplineread-p"task name: " task
git checkout -b"$(whoami)/${task}"cat<<EOF
Discipline reminder for this task:
1. Comment/docstring FIRST (5-clause: dialect, source, grain, filters, output)
2. Wait for ghost text; verify against comment; iterate on comment not on SQL
3. Before commit: precommit runs dbt parse + sqlfluff + hallucination scan
4. PR body: paste the comment header + link to the intent doc
EOF
;;
precommit)# explicit precommit run (git will also invoke this automatically)
pre-commit run --all-files;;
review)# helper for reviewing a teammate PRread-p"PR number: "pr
gh pr checkout "$pr"
gh pr diff "$pr" | less
echo"Review checklist:"echo" [ ] Read every line"echo" [ ] Comment contract matches the SQL"echo" [ ] Ran query in dev at least once"echo" [ ] Checked EXPLAIN plan for any new join"echo" [ ] Row count drift acceptable (< 5%)"echo" [ ] .cursorrules do-nots not violated";;
evening)# 5-minute end of day
git status
if[-n"$(git status --porcelain)"];then
echo"WARN: uncommitted changes"fi# sync .cursorrules if any convention changedif git log --since=today --oneline dbt_project.yml profiles.yml | grep-q .;then
echo"→ dbt config changed today — review .cursorrules"fi;;
weekly)# 30-minute weekly hygiene
python scripts/copilot_hallucination_scan.py --window 7d
python scripts/hallucination_rate.py --pr-window 7d
grep-rn"TODO(rules)" .cursorrules ||echo"no pending rules updates";;help|*)echo"usage: $0 {morning|task|precommit|review|evening|weekly}";;esac
Step-by-step explanation.
morning seeds the local state/manifest.json for --defer compile runs later in the day. This is what makes dbt compile --select state:modified --defer fast — it compares the current state to the morning snapshot.
task creates a branch and prints the four-step discipline reminder. The comment-first / iterate-on-comment mantra is the highest-leverage habit; printing it at task-start makes it hard to skip.
precommit explicitly runs the pre-commit hooks. In practice, git commit runs them automatically, but running them manually before commit lets you fix issues without a rejected commit.
review checks out a teammate PR, shows the diff, and prints the six-item review checklist. Every checklist item corresponds to a failure mode that has shipped in production somewhere; the checklist is a memento mori of past incidents.
evening catches uncommitted work and prompts a .cursorrules sync if dbt config changed today. This is what keeps the rules file synchronised with the actual repo state.
weekly runs the hallucination-rate metric. Trending do-not violations upward is the earliest signal that the rules file needs an update or that a new failure mode has appeared.
Output.
Phase
Time
Frequency
morning
5 min
daily
task
30–60 min × 2–3
daily
precommit
30 s (auto)
per commit
review
10–15 min × 3–5
daily
evening
5 min
daily
weekly
30 min
Mondays
Rule of thumb. Codify the daily rhythm as a shell script. Print the discipline reminders at task-start; print the review checklist at PR-checkout time. The disciplines that live only in your head fade; the disciplines that print themselves on your terminal every morning stick.
Worked example — precommit hooks that catch what CI can't
Detailed explanation. The precommit stage is where the fastest, cheapest guardrails run — before the code leaves the engineer's machine. Failed precommits produce fast feedback; failed CI produces slow feedback and a rejected PR. The senior discipline is to move as many checks as possible from CI to precommit. Walk through a production precommit config.
Compile lane.dbt parse (~5 s) + dbt compile --select state:modified --defer (~10 s per changed model).
Lint lane.sqlfluff (~2 s per file) + sqlmesh diff (~5 s if enabled).
Lane 1 (compile) has two hooks. dbt-parse is fast (~5 s) and catches unresolved ref(), invalid Jinja, and missing macros. dbt-compile-changed uses --select state:modified --defer to compile only the models whose upstream state has changed — this keeps precommit fast even in a 300-model repo.
Lane 2 (lint) uses sqlfluff with the dbt templater — this lets sqlfluff understand {{ ref() }} and {{ source() }} without treating them as syntax errors. sqlfluff-lint reports; sqlfluff-fix auto-corrects.
Lane 3 (custom) runs the hallucination-flag scan and the DAG deprecated-API grep. Both are cheap (~1 s each) and catch the top-N specific failure modes that the generic lint tools miss.
The style hooks (black, isort) are last so the auto-fixers run after the semantic checks pass. This ordering avoids the "black reformatted the file and now sqlfluff has different line numbers in errors" confusion.
Total precommit cost is ~15–30 seconds per commit. This is fast enough to run every commit; slow enough that engineers rarely bother to --no-verify. The right cost curve.
Output.
Lane
Hooks
Cost
Catches
Compile
dbt-parse, dbt-compile-changed
~15 s
unresolved refs, invalid Jinja
Lint
sqlfluff-lint, sqlfluff-fix
~5 s
style + templated-SQL syntax
Custom
hallucination-scan, dag-lint
~2 s
specific failure modes
Style
black, isort
~2 s
code style
Rule of thumb. Precommit runs every commit and must stay under ~30 seconds total. Move as many checks as possible from CI to precommit; keep only the expensive checks (full dbt build, dbt test) in CI. Fast feedback matters; slow rejection cycles do not.
Worked example — the LLM-authored SQL incident review pattern
Detailed explanation. When an incident traces to LLM-authored SQL, the review must go beyond "revert the PR." The senior discipline is: (a) identify the exact hallucination or drift, (b) trace it to a missing guardrail, (c) patch the guardrail, (d) grep the repo for the same pattern elsewhere. Walk through a real incident pattern.
Symptom. A dashboard reports 3% row count drop overnight.
Cause. A dbt model merged yesterday joined on customer_key (invented by Copilot) instead of customer_id; the join silently returned an empty set for a segment.
Detection. The sqlmesh diff in CI flagged the model's row count moved by 3%, but the warning was ignored.
Fix. Revert; add a .cursorrules do-not clause "never invent column names — ask for schema.yml"; add a precommit hook that verifies every JOIN's ON clause references columns from the referenced tables; grep the repo for other JOIN-on-invented-columns.
Question. Write the incident review + guardrail patch, and demonstrate the repo-wide grep that would catch the same pattern elsewhere.
Input.
Aspect
Value
Incident
3% row-count drop on mart_customer_orders dashboard
Root cause
JOIN on customer_key (invented); should be customer_id
# INCIDENT-2026-07-29.md — post-incident review## Timeline- 2026-07-28 15:03 PR #4821 merged: `int_orders__enriched.sql` refactor
- 2026-07-28 15:15 CI sqlmesh diff warned: row_count -3.1% (ignored)
- 2026-07-28 22:00 overnight dbt run applies the model to prod
- 2026-07-29 08:12 dashboard "Customer Orders — Daily" flagged 3% drop
- 2026-07-29 08:35 root cause: JOIN on `customer_key` — column does not exist
on dim_customers; silently returned NULL, filtered out
- 2026-07-29 08:50 reverted PR #4821; deployed hotfix
## Root cause
Copilot generated a JOIN clause `ON o.customer_id = c.customer_key`.
`c.customer_key` does not exist on dim_customers (correct column is
`customer_id`). Snowflake accepted the SQL because `customer_key` looked
like it could be a valid column name; the join silently returned NULL
for that side; the WHERE filter dropped the NULLs; row count fell 3%.
The Copilot completion was made in a session where the engineer had not
opened dim_customers's schema.yml in the context window. Copilot guessed
the column name from stem-name heuristics.
## Guardrail gaps1.`.cursorrules` did not have "never invent column names" as a do-not.
2. precommit did not verify JOIN ON columns exist in referenced tables.
3. sqlmesh diff warning was fired but not blocking.
4. human review approved without opening the schema.yml.
## Fix1. Added to `.cursorrules`:
"Never invent a column name. Before proposing a JOIN, verify both
columns exist by opening schema.yml or asking the user."
2. Added precommit hook `verify_join_columns.py` that parses new/changed
SQL, extracts JOIN ON clauses, and cross-references the columns
against the referenced models' schema.yml.
3. Made sqlmesh diff warnings blocking on row count movements > 2%.
4. Updated PR review template: "opened schema.yml for every JOIN? [ ]"
## Repo-wide sweep
Grep run to catch the same pattern in other models:
Result: 2 additional models flagged with likely-invented column names
in JOINs; both fixed in follow-up PR #4835.
# scripts/verify_join_columns.py — the new precommit hook
"""
Parse every SQL file and verify JOIN ON columns exist in the referenced
models' schema.yml entries. Fails loudly on any suspicious join.
"""importre,sys,yamlfrompathlibimportPathfromcollectionsimportdefaultdict# 1. Load schema.yml -> {model_name: [column_names]}
schema_columns=defaultdict(set)foryml_pathinPath("dbt_project/models").rglob("schema.yml"):doc=yaml.safe_load(yml_path.read_text())formindoc.get("models",[]):forcinm.get("columns",[]):schema_columns[m["name"]].add(c["name"])# 2. Simple regex — {{ ref('model_name') }} <alias>
REF=re.compile(r"\{\{\s*ref\(\s*'([^']+)'\s*\)\s*\}\}\s+(\w+)",re.I)JOIN=re.compile(r"JOIN\s+\{\{\s*ref\(\s*'([^']+)'\s*\)\s*\}\}\s+(\w+)\s+"r"(?:USING\s*\((\w+)\)|ON\s+([\w\.]+)\s*=\s*([\w\.]+))",re.I|re.S,)# 3. Walk staged files
violations=[]forfninsys.argv[1:]:text=Path(fn).read_text()# Build alias -> model map from the FROM / JOIN clauses
alias_to_model=dict(REF.findall(text))forminJOIN.finditer(text):_,_,using_col,left_expr,right_expr=m.groups()# For each column in the ON clause, verify it exists in its model
forexprin(left_expr,right_expr):ifnotexpr:continuealias,_,col=expr.rpartition(".")model=alias_to_model.get(alias)ifmodelandmodelinschema_columnsandcolnotinschema_columns[model]:violations.append(f"{fn}: JOIN references {alias}.{col} — "f"column not in schema.yml for {model}")ifusing_col:foralias,modelinalias_to_model.items():ifmodelinschema_columnsandusing_colnotinschema_columns[model]:violations.append(f"{fn}: JOIN USING ({using_col}) — "f"column not in schema.yml for {model}")ifviolations:print("verify_join_columns flagged:")forvinviolations:print(""+v)sys.exit(1)
Step-by-step explanation.
The post-incident timeline records the causal chain: PR merged → sqlmesh warning ignored → overnight run → dashboard alarm → root cause identified → revert. Writing this timeline is the first discipline; it forces you to identify every guardrail that could have caught it.
The root cause identifies the specific failure mode (invented column name) and the human contributor (schema.yml not opened during Copilot session). Naming both — the model failure and the human process failure — is required.
The guardrail-gap section lists the four lanes that should have caught this: .cursorrules, precommit, CI, human review. Every incident maps to at least one gap; often multiple.
The fix section is the patch to each lane: add a do-not clause, add a precommit hook, upgrade a CI warning to blocking, update the PR template. Multi-lane fix for a multi-lane failure.
The repo-wide sweep uses the new precommit hook to grep for the same pattern elsewhere. This is what turns a one-incident fix into a class-of-incident fix.
Output.
Lane
Gap
Patch
.cursorrules
no "never invent column names" clause
added
precommit
no JOIN-column verifier
added verify_join_columns.py
CI
sqlmesh warning was non-blocking
upgraded to blocking on > 2% drift
human review
schema.yml not opened
updated PR template checkbox
repo sweep
pattern lurked in 2 other models
fixed in PR #4835
Rule of thumb. Every LLM-related incident review ends with (a) named root cause, (b) named guardrail gap per lane, (c) patched lane, (d) repo-wide grep for the same pattern. Skipping the grep is the most common way an incident recurs.
Senior interview question on guardrails and review discipline
A senior interviewer might ask: "You've inherited an analytics-engineering team that adopted Copilot six months ago. Three production incidents have traced to LLM-authored SQL — invented column names, wrong dialect, deprecated Airflow operator. Walk me through the three-lane guardrail you'd standardise, the precommit config, the CI upgrades, the review-discipline PR template, and the metrics you'd track to know it's working."
Solution Using a three-lane precommit + CI, a review PR template, and a weekly hallucination-rate metric
<!-- .github/pull_request_template.md -->## Description<!-- what does this PR do and why -->## LLM co-authorship- [ ] This PR includes Copilot / Cursor-generated code
- [ ] Comment-first prompts were used
- [ ] `.cursorrules` do-nots were respected
## Guardrail lanes- [ ] Precommit passed (dbt parse, compile, sqlfluff, hallucination scan)
- [ ] CI passed (dbt build --select state:modified+, sqlmesh diff, DAG imports, pytest)
- [ ] Ran at least one query in dev against representative data
- [ ] Checked EXPLAIN plan for any new JOIN
- [ ] Opened schema.yml for every JOINed model
- [ ] Row-count drift (from sqlmesh diff) is < 2% or explained in the description
## For DAGs- [ ] Module docstring: purpose, schedule, upstream, owner, retries, SLA, tasks
- [ ] TaskFlow API (@dag / @task), not PythonOperator
- [ ] Every Sensor has mode='reschedule' and explicit timeout
- [ ] pytest fixture at tests/dags/test_<dag_id>.py asserts import + task list + deps
After the 30-day standardisation, the three prior incidents' failure modes are caught at precommit (wrong dialect, deprecated API) or precommit + CI (invented JOIN columns), and the weekly hallucination-rate metric drops from ~15% to ~2%. The team ships more PRs faster with fewer post-merge defects — the three-lane guardrail turns the LLM workflow from "risky productivity" into "safe productivity."
Output:
Metric
Before
After (30 d)
Precommit failure rate
8% (mostly style)
4% (mostly semantic, caught early)
CI failure rate
15% (mostly dbt build)
3%
Post-merge defects / 100 PRs
12
2
Hallucination-flag rate / merged PR
15%
2%
PRs / engineer / week
3.5
7.0
Mean PR lifetime
2.1 d
0.9 d
Why this works — concept by concept:
Three-lane guardrail — compile, lint, human review each catch a different class of failure. Compile catches syntax; lint catches style + row-count drift; review catches intent mismatch. Missing any one lane leaves a class of defect uncovered.
Move as many checks as possible to precommit — precommit gives 15-second feedback; CI gives 3-minute feedback. Fast feedback is the difference between "engineer fixes it while still in flow" and "engineer forgets what they were doing and comes back tomorrow."
Blocking sqlmesh diff on row-count drift — turning a warning into a blocker is the specific fix for the "silently wrong SQL that compiles fine" class of incident. 2% threshold catches the meaningful drifts without over-firing on legitimate refactors.
PR template checkboxes — the template turns implicit disciplines (open schema.yml, verify EXPLAIN, check row count) into explicit checkboxes reviewers can hold each other accountable to. The template is the code review's .cursorrules.
Cost — one precommit config (~50 lines), one CI workflow (~30 lines), one PR template (~20 lines), one weekly metric script. The payback is a ~7× drop in hallucination-flag rate and 2× PR throughput. Compared to letting each engineer figure out their own review discipline, this is O(1) config for O(N × week) engineer-hours saved.
SQL Topic — cte/sql CTE and refactor problems for review discipline
Editor selection by task shape. For single-file mechanical edits, Cursor inline or Copilot inline; sub-300-ms ghost text keeps flow. For multi-file reasoning tasks, Cursor Composer or Copilot Chat with @Codebase/@workspace context injection. For overnight or repo-wide refactors, Codex CLI, Claude Code, or Aider with the strictest 3-lane guardrail. Never let agentic mode land PRs without human review; never spend Composer latency on tasks that fit in inline ghost text.
The .cursorrules template that ships hallucination rate ~4× lower. Sections in order: role + voice, repo shape, naming conventions, SQL style + dialect, dbt idioms, Airflow idioms, do-nots, guardrail expectations. Under ~1500 tokens. Versioned in the repo root; reviewed in PRs like any config. Do-nots are more effective than do's — "never invent a column name" beats "prefer real column names." Include "when you don't know, say so and ask" as the final clause to convert silent hallucination into loud "please paste the schema."
The comment-first Copilot prompt. Five-clause header comment: dialect, source table + upstream refs, grain (one row per what), filters (WHERE conditions), output columns. Write the comment first; wait for ghost text; verify against the comment; iterate on the comment (not on the SQL) if the completion drifts. This one discipline is the single most transferable Copilot habit.
The dbt model docstring pattern. Every model has a schema.yml entry with description + columns + tests. Every macro has a {% docs %} block. Every dbt-file top-of-file comment names dialect + source refs + grain — this is the Copilot inline anchor, redundant with .cursorrules but load-bearing when tabs are stale.
The Airflow DAG scaffold prompt. Module docstring with seven fields: DAG id, purpose, schedule, upstream (sensor keys or trigger DAG), owner + on-call rotation, retry policy (retries + delay + exponential backoff), SLA, task list. Then @dag decorator; then @task / @task_group decorators. Then dependency graph with explicit >>. Then a matching pytest fixture at tests/dags/test_<dag_id>.py.
The precommit three-lane block. Lane 1 (compile): dbt parse + dbt compile --select state:modified --defer. Lane 2 (lint): sqlfluff lint --dialect <dialect> --templater dbt. Lane 3 (custom): hallucination-scan for wrong-dialect + deprecated-API patterns, plus verify_join_columns.py for JOIN-column existence. Total ~15–30 s per commit; catches ~90% of what CI would catch 3 minutes later.
The CI heavier lane.dbt build --select state:modified+ (compile + tests on modified subgraph), sqlmesh diff with 2% row-count-drift threshold as blocking, DAG import test, pytest for DAGs. Runs on every PR; blocks merge on any failure. Fetch the main-branch state manifest before running so --defer works against fresh state.
The PR review template. Checkboxes: LLM co-authorship, comment-first prompt used, .cursorrules respected, precommit passed, CI passed, ran query in dev, checked EXPLAIN, opened schema.yml for every JOIN, row-count drift < 2% or explained. For DAGs: TaskFlow API, sensor mode='reschedule' + timeout, pytest fixture asserts task list + deps + SLA + retries.
The daily rhythm. Morning: pull main, snapshot state/manifest.json, reset Cursor tabs, reindex codebase. Task loop: comment/docstring first, ghost text, verify, precommit, PR. Review loop: read every line, verify comment contract, run one query, check EXPLAIN. Evening: close tabs, sync .cursorrules if any convention changed. Weekly: hallucination-rate metric, .cursorrules update if new failure modes appeared.
The hallucination-rate metric. Weekly script that greps merged-PR diffs for known do-not patterns (wrong dialect, deprecated Airflow API, hardcoded schema, SELECT * in mart, JOIN on unknown column). Report absolute count + rate. Trending upward = missing rules clause or missing precommit hook. Target: < 3% flag rate on merged PRs.
The incident-review-to-guardrail-patch loop. Every LLM-related incident ends with: (a) named root cause, (b) named guardrail gap per lane, (c) patched lane (new .cursorrules clause, new precommit hook, new CI check, or updated PR template), (d) repo-wide grep for the same pattern in other models / DAGs. Skipping the grep is the most common way an incident recurs.
Context-window hygiene. Close unused Cursor tabs at end of session. Reindex codebase after schema changes. Pin .cursorrules + dbt_project.yml + profiles.yml via .cursor/settings.jsonautoInclude. Grep for stale model names in .cursorrules weekly. Keep the rules file under ~1500 tokens; verbose examples go in docs/ and get pulled via @Docs.
When to escalate from inline to Composer to CLI. Inline for single-file mechanical (add a column, refactor a filter). Composer / Chat for multi-file with reasoning (rename model across 5 references, generate schema.yml from a new model). CLI / agentic for repo-wide (deprecate a macro across 40 models, generate exposures for every dashboard). Each escalation multiplies both leverage and required review depth; never escalate without proportional guardrail depth.
The "when you don't know, say so" clause. Both .cursorrules and the comment-first pattern should invite the LLM to ask for clarification instead of guessing. Silent hallucination is expensive; loud "please paste the schema for dim_customers" is cheap. Rewarding the ask, not the guess, is the highest-leverage prompt-engineering move.
Frequently asked questions
What is Cursor and why does it matter for SQL engineers in 2026?
Cursor is a VS Code fork with LLM-native features built in from the ground up: .cursorrules for repo-scoped prompt injection, @Codebase / @Docs / @Files symbol injection for context-window control, inline ghost-text completion tuned for sub-300-ms latency, and Composer for multi-file agentic edits. It matters for cursor for sql engineers because SQL authoring in a dbt monorepo is a context-heavy task — the LLM must know the model layer hierarchy, the naming conventions, the dbt macros installed, the SQL dialect (Snowflake vs BigQuery vs Postgres), the schema.yml contracts — and Cursor's context-injection model is uniquely well-suited to that shape. Every senior data engineer in 2026 who has adopted .cursorrules + comment-first prompting reports 2–3× PR throughput on analytics-engineering work with stable or improving defect rates. Cursor doesn't replace SQL literacy; it amplifies it, and the engineers who configure it well outproduce the ones who don't by a wide margin.
GitHub Copilot vs Cursor for dbt authoring — which should I pick?
Both, actually — they solve different sub-problems. GitHub Copilot inline is unmatched for sub-300-ms ghost-text completions at the caret: line-by-line SQL, Jinja fills, dbt macro completions, small edits. Its github copilot sql completions are fastest and most predictable when you use the comment-first pattern. Cursor is unmatched for repo-scoped context (.cursorrules, @Codebase), multi-file edits (Composer), and integrated Chat with @Docs injection. The senior workflow uses both: Cursor as the editor with .cursorrules and Composer for multi-file work, plus GitHub Copilot enabled inside Cursor for the fastest ghost-text completions. cursor rules for sql are what pin dialect + naming; Copilot's inline model is what makes each line arrive quickly. Never pick one and disable the other — the failure modes are complementary and the productivity multiplier is compounded.
How do I keep an LLM from hallucinating column names in my SQL?
Four defenses, in order of leverage. First, put the SQL dialect and the source tables' expected columns in a file-header comment or in .cursorrules; this is the cheapest fix. Second, use Cursor's @Codebase <model> schema.yml symbol injection (or open the schema.yml in a sibling tab in Copilot) so the LLM sees the actual column list. Third, add a precommit hook (verify_join_columns.py) that parses JOIN ON clauses and cross-references the columns against schema.yml — this catches invented column names before commit. Fourth, in .cursorrules, add the do-not clause "never invent a column name; ask for the schema.yml if unclear" — this converts silent hallucination into loud clarification requests. Combine all four and the invented-column-name failure mode falls from ~1 in 10 completions to ~1 in 100. The single incident every senior data engineer remembers is the one where an invented column name silently shipped and dropped 3% of dashboard rows overnight; the four defenses above are the composite fix.
Can I trust Copilot or Cursor to write Airflow DAGs?
Yes, with the docstring-first discipline and a three-lane guardrail. airflow dag copilot authoring works well when (a) you write a top-of-file module docstring naming purpose, schedule, upstream, owner, retry policy, SLA, and task list — the LLM scaffolds the DAG from that; (b) .cursorrules do-not clauses prohibit airflow.operators.python_operator (deprecated), mandate TaskFlow API (@dag/@task), and require mode='reschedule' + explicit timeout on every Sensor; (c) every DAG ships with a matching tests/dags/test_<dag_id>.py pytest fixture asserting import, task list, dependency graph, SLA, retries. CI runs a DAGBag import test that catches parse errors, plus pytest, plus a grep for deprecated APIs. With those three lanes in place, LLM-authored DAGs are as safe as hand-authored ones — the LLM just gets you to the reviewed draft ~5× faster. Without them, LLM-authored DAGs ship polling loops inside PythonOperators and deprecated operator imports on a regular basis.
What should a .cursorrules file contain for a dbt + Airflow repo?
Seven sections in this order. Role + voice — "you are pair-programming with a senior analytics engineer on the <repo> monorepo." Repo shape — the folder layout, dbt project structure, DAG folder. Naming conventions — model prefixes (stg_, int_, dim_, fct_), macro naming, DAG id conventions. SQL style + dialect — Snowflake / BigQuery / Postgres, CTE-first, ORDER BY at the end, QUALIFY over subqueries. dbt idioms — schema.yml required per model, incremental config template, macro documentation via {% docs %}. Airflow idioms — TaskFlow API only, sensor operators over polling loops, retry policy on network operators. Do-nots — the specific anti-patterns to prohibit (invented column names, deprecated APIs, hardcoded schema references). Guardrail expectations — what tools will check the completion (dbt parse, sqlfluff, human review) so the LLM self-checks. Keep the whole file under ~1500 tokens; version it in the repo root; review it in PRs whenever a convention changes. This is the highest-leverage config in the whole llm-native sql stack.
Is LLM-native SQL authoring safe for production data pipelines?
Yes, when the three-lane guardrail is in place; no, when any lane is missing. The compile lane (dbt parse + dbt compile --select state:modified --defer) catches unresolved refs and invalid Jinja. The lint lane (sqlfluff + sqlmesh diff with 2% row-count-drift threshold) catches style and semantic drift. The human review lane catches intent-code mismatches that neither compile nor lint can see. Every production incident traced to LLM-authored SQL — and every senior data engineer has at least one — maps to one or more missing lanes: the wrong-dialect hallucination that shipped because the precommit didn't check dialect, the invented column name that shipped because sqlmesh diff was non-blocking, the deprecated Airflow operator that shipped because the CI grep didn't exist. The pattern is always the same: identify the missing lane, add the check, grep the repo for the same pattern elsewhere. With three lanes and a weekly hallucination-rate metric, LLM-authored SQL is safer than hand-authored SQL because the lanes catch what humans miss; without three lanes, it's a source of slow-motion production incidents that only surface days later in dashboards or reconciliation reports. Configure the lanes; trust the workflow.
Practice on PipeCode
Drill the SQL practice library → for the CTE, join, window-function, and aggregation problems that map directly onto the comment-first Copilot pattern.
Sharpen the pipeline axis with the ETL practice library → for the Airflow DAG scaffolding, sensor-driven, and incremental-load patterns senior interviewers love.
Stack the design axis with the design practice library → for the repo-scoped tooling and guardrail-architecture scenarios that every LLM-workflow interview probes.
Stack the prerequisites against PipeCode's broader 450+ data-engineering catalogue to anchor the .cursorrules + three-lane guardrail against real graded inputs.
Lock in LLM-native SQL authoring muscle memory
Docs explain features. PipeCode drills explain the discipline — when a comment-first prompt is worth its 30 seconds, when `.cursorrules` prevents a hallucinated column, when a three-lane guardrail catches what code review misses, when the docstring-first DAG scaffold turns a 60-minute task into a 10-minute review. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the LLM-native SQL, dbt, and Airflow authoring workflows senior data engineers actually ship.
Top comments (0)