◐ ai-teacher

🛡 Quality, Testing & Ops · Chapter 8

Git for data teams: branching, PRs, review, CI/CD

In application code, a bad merge breaks a feature. In a data repo, a bad merge rewrites history — every table downstream recomputes with the wrong logic. Data teams need the same Git discipline plus one extra question on every PR: what does this do to the data that already exists?

Study this lesson free in the app →

The basics

Git for data teams is the workflow a team uses to change pipeline code safely together: short-lived branches, pull requests that are reviewed, and automated checks (CI) that build and test the change before it reaches production (CD).

Why it exists: five engineers editing the same dbt project and Airflow DAGs will overwrite each other, ship untested SQL and break dashboards. A shared workflow means every change is small, visible, tested on real data shapes and reversible.

What's different for data: code changes are cheap to revert, data changes aren't. A changed fare formula alters every row it rebuilds. So a data PR also needs: which tables change, how many rows differ from production, and whether a backfill is needed.

The mental model:

branch → commit small → open PR → CI builds changed models in a
throwaway schema + tests + data diff → review → merge to main
→ CD deploys to prod → (backfill if needed)

Words to know: feature branch, pull request, branch protection, CI, CD, slim CI, backfill, CODEOWNERS.

Explained

Friday 6 PM. A PR titled “small cleanup” changes fct_rides from JOIN payments to LEFT JOIN payments. 14 lines. Approved with “LGTM 👍” in 40 seconds.

Saturday's run: rides with two payment attempts now appear twice. Driver payouts on Monday are ₹2.8 lakh too high.

The code review looked at code. Nobody looked at what it did to the data. A CI data diff would have printed: fct_rides rows: prod 95,214 → PR 97,031 (+1.9%).

The shared recipe book

A restaurant chain has one master recipe book used by 40 kitchens.

A chef can't just cross out ‘2 tsp salt’ and write ‘2 tbsp’. He writes the change on a slip (branch), cooks a test batch in the test kitchen (CI), and the head chef tastes it next to the current dish (review with a data diff). Only then is the page replaced (merge), and the new page is sent to all kitchens (deploy).

And one extra question: what about the 300 jars of sauce already made with the old recipe? Throw them out and remake (backfill), or label them and let them run out? Software teams rarely ask that. Data teams must, every time.

Slim CI for a dbt repo (GitHub Actions)

# .github/workflows/ci.yml
on: pull_request
jobs:
  dbt-ci:
    runs-on: ubuntu-latest
    env:
      DBT_SCHEMA: pr_${{ github.event.number }}      # throwaway schema per PR
      SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}
    steps:
      - uses: actions/checkout@v4
      - run: pip install dbt-snowflake sqlfluff
      - run: sqlfluff lint models/ --dialect snowflake
      - run: ./scripts/fetch_prod_manifest.sh prod_artifacts/   # last prod manifest.json
      - run: dbt deps
      - run: >
          dbt build --target ci
          --select state:modified+
          --defer --state prod_artifacts
      - run: python scripts/data_diff.py --schema $DBT_SCHEMA   # posts diff as PR comment

state:modified+ = models changed in this PR and everything downstream. --defer makes unchanged parents read from production instead of rebuilding them. A 400-model project builds 6 models in CI instead of 400.

What a reviewer actually checks on a data PR

Style is the linter's job. The human checks:

A good PR description answers all of these before the reviewer asks.

The merge conflict that wasn't a conflict

Asha's branch (5 days old) adds a surge_multiplier column to fct_rides. Meanwhile Rohan merged a change renaming fare to fare_inr in the same model.

Git merges cleanly — different lines. CI on Asha's PR fails: her new line computes fare * surge_multiplier, and fare no longer exists.

Two lessons:

  1. Git conflicts are textual; data conflicts are semantic. Only CI that actually builds the model catches the second kind.
  2. Long-lived branches are the cause. Rebasing on main daily (git fetch && git rebase origin/main) would have shown it on day 1, not day 5.

Gotchas

Small branch, CI that builds what changed, a review that reads the data diff, and a backfill plan.

Code is cheap to revert. Data isn't — so every PR answers: what happens to the rows that already exist?

How to answer “what does your team's CI/CD look like?”

“Trunk-based with short-lived branches; main is protected, so every change is a PR with green CI and an approval, and CODEOWNERS routes finance models to the finance-data reviewers. CI lints SQL with sqlfluff, then runs dbt build on state:modified-plus with defer, in a schema per PR, so we only build changed models and their children. A script posts a data diff to the PR — row counts, distinct keys and fare totals against prod. On merge, CD deploys to production. Anything that changes history ships with a backfill plan in the PR description, and we run the backfill as a separate step.”

Follow-up: “How do you avoid CI being slow?” → slim CI with state selection and defer, plus where windows on tests.

Words to know

Feature branchA short-lived branch for one change, merged back to main via a pull request.
Branch protectionRules on main: no direct pushes, required CI checks and reviews before merge.
Slim CIBuilding and testing only modified models and their descendants, deferring unchanged parents to production.
--deferdbt flag that resolves refs to unbuilt models against a production manifest instead of rebuilding them.
Data diffA comparison of a PR's output tables against production: row counts, keys, key metrics.
BackfillRecomputing historic partitions with new logic. A separate, deliberate step after deploying.
CODEOWNERSA file mapping paths to required reviewers, e.g. finance models to the finance-data team.

In two minutes

Test yourself

1. What does dbt's --select state:modified+ choose?

  1. All models
  2. Models changed in the last 24 hours in production
  3. Only models with failing tests
  4. Models changed in this PR plus everything downstream of them
Show the answer

Models changed in this PR plus everything downstream of them — state:modified compares against a production manifest; the + adds descendants, because a change can break anything that reads from it.

2. Why give each PR its own schema in CI (e.g. pr_482)?

  1. So concurrent PRs don't overwrite each other's tables and test results
  2. It's required by Git
  3. To save storage
  4. So production can read from it
Show the answer

So concurrent PRs don't overwrite each other's tables and test results — Shared CI schemas cause races: PR A's tests run against PR B's tables. Isolation makes results trustworthy; drop the schema when the PR closes.

3. A PR changes the fare rounding rule in fct_rides and is merged. What happens to last month's rows?

  1. They update automatically
  2. They are deleted
  3. Nothing — only future runs use the new rule until you backfill
  4. Git reverts them
Show the answer

Nothing — only future runs use the new rule until you backfill — Deploying changes code, not existing data. Historic partitions keep the old logic, so the table mixes both rules until a deliberate backfill.

4. Which is the most valuable thing to show a reviewer on a data PR?

  1. A screenshot of the code editor
  2. A data diff: rows, distinct keys and fare totals vs production
  3. The number of lines changed
  4. The commit count
Show the answer

A data diff: rows, distinct keys and fare totals vs production — Reviewers can't execute SQL in their heads. A diff showing +1.9% rows on a model that should be unchanged reveals fan-out instantly.

5. A developer accidentally pushed a Snowflake password in profiles.yml, then deleted the file in the next commit. What must happen?

  1. Rotate the credential immediately; it's still in Git history
  2. Nothing — it's deleted
  3. Make the repo private
  4. Squash the two commits
Show the answer

Rotate the credential immediately; it's still in Git history — History keeps every committed version, and clones or forks may already have it. Rotation is the only real fix; history rewriting is secondary.

6. Trap: two branches edit different lines of fct_rides.sql; Git merges without conflict. Is the merged model safe?

  1. Yes — no conflict means compatible
  2. No — Git always corrupts SQL
  3. Yes, if both PRs had passing CI separately
  4. Not necessarily — one may reference a column the other renamed; only building it proves it
Show the answer

Not necessarily — one may reference a column the other renamed; only building it proves it — Git detects textual conflicts, not semantic ones. Each PR passed against an older main. CI must run on the merged result (rebase or merge queue).

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →