Claude Code in CI/CD: Review Data Pipeline Pull Requests with GitHub Actions (Claude for Data Engineers Part 9)

Claude for Data Engineers · Part 9 of 10

In this post we are going to cover how to put Claude Code into CI/CD and use it to review pull requests for a data pipeline with Claude Code GitHub Actions. We set up the workflow file, see how @claude works on a pull request, switch to automation mode with a prompt, run the same review on our own machine with the real build and data checks, and the last step is fixing the bug the review finds.

Claude Code GitHub Actions runs Claude Code inside a GitHub workflow. Someone writes @claude in a pull request comment, or the workflow runs on its own with a fixed prompt, and Claude reviews the change and runs the commands you allow. For a data engineer this is a second pair of eyes on every SQL change. Our pull request adds refunds to a daily revenue model. It looks fine, but it has a classic join bug. Let's see if the review finds it.

Part 9 of the Claude for Data Engineers series on TechBrothersIT.

Let's create some sample data

Our project is shop-pipeline again: customers, orders and payments as CSV files, a build.py that loads them into DuckDB and builds the models, two SQL models, and a checks/run_checks.py with five data checks. The repo already has the Claude workflow in .github/workflows/claude.yml and a headless review script in ci/review.sh. Look closely at payments.csv: order 1004 has two payment rows, payment 3 and payment 12. The setup commands commit everything to main, then create the pull request branch add-refunds with the refunds change.

data/customers.csv
customer_id,name,country,signup_date
101,Ava Brooks,US,2026-08-02
102,Liam Chen,CA,2026-08-05
103,Noah Patel,US,2026-08-11
104,Mia Garcia,MX,2026-08-19
105,Zoe Martin,UK,2026-09-01
106,Omar Khan,US,2026-09-03
data/orders.csv
order_id,customer_id,order_date,status,amount
1001,101,2026-10-01,completed,120.00
1002,102,2026-10-01,completed,75.50
1003,103,2026-10-01,cancelled,40.00
1004,101,2026-10-02,completed,210.00
1005,104,2026-10-02,completed,35.25
1006,105,2026-10-02,completed,99.00
1007,106,2026-10-03,completed,150.00
1008,102,2026-10-03,completed,64.75
1009,103,2026-10-03,completed,88.00
1010,104,2026-10-04,completed,45.00
1011,105,2026-10-04,completed,130.00
1012,106,2026-10-04,completed,72.00
data/payments.csv
payment_id,order_id,paid_at,amount,refund_amount
1,1001,2026-10-01,120.00,0.00
2,1002,2026-10-01,75.50,0.00
3,1004,2026-10-02,150.00,30.00
4,1005,2026-10-02,35.25,0.00
5,1006,2026-10-02,99.00,0.00
6,1007,2026-10-03,150.00,0.00
7,1008,2026-10-03,64.75,10.00
8,1009,2026-10-03,88.00,0.00
9,1010,2026-10-04,45.00,0.00
10,1011,2026-10-04,130.00,0.00
11,1012,2026-10-04,72.00,0.00
12,1004,2026-10-02,60.00,0.00
build.py
import pathlib
import duckdb

con = duckdb.connect("shop.duckdb")
for table in ("customers", "orders", "payments"):
    con.execute(f"CREATE OR REPLACE TABLE {table} AS SELECT * FROM read_csv('data/{table}.csv', header = true)")

for f in sorted(pathlib.Path("models").glob("*.sql")):
    con.execute(f"CREATE OR REPLACE VIEW {f.stem} AS " + f.read_text())
    rows = con.execute(f"SELECT count(*) FROM {f.stem}").fetchone()[0]
    print(f"built {f.stem:<16} {rows:>3} rows")
models/daily_revenue.sql
select
    o.order_date,
    count(*)        as orders,
    sum(o.amount)   as revenue
from orders o
where o.status = 'completed'
group by o.order_date
order by o.order_date
models/customer_orders.sql
select
    o.order_id,
    o.order_date,
    o.amount,
    c.customer_id,
    c.name,
    c.country
from orders o
join customers c on c.customer_id = o.customer_id
checks/run_checks.py
"""Data checks for the shop pipeline. Exit code 1 when any check fails."""
import sys

import duckdb

con = duckdb.connect("shop.duckdb", read_only=True)
COMPLETED = "from orders where status = 'completed'"
CHECKS = {
    "orders: duplicate order_id": "select order_id from orders group by order_id having count(*) > 1",
    "orders: unknown customer": "select order_id from orders where customer_id not in (select customer_id from customers)",
    "payments: unknown order": "select payment_id from payments where order_id not in (select order_id from orders)",
    "daily_revenue: revenue": f"select m || ' vs ' || o from (select (select sum(revenue) from daily_revenue) m, (select sum(amount) {COMPLETED}) o) where m <> o",
    "daily_revenue: orders": f"select m || ' vs ' || o from (select (select sum(orders) from daily_revenue) m, (select count(*) {COMPLETED}) o) where m <> o",
}
print(f"{'check':<28} result")
failed = 0
for name, sql in CHECKS.items():
    bad = [str(r[0]) for r in con.execute(sql).fetchall()]
    failed += bool(bad)
    print(f"{name:<28} " + ("FAIL  " + ", ".join(bad) if bad else "pass"))
print(f"{failed} of {len(CHECKS)} checks failed")
sys.exit(1 if failed else 0)
.github/workflows/claude.yml
name: Claude Code
on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
jobs:
  claude:
    if: contains(github.event.comment.body, '@claude')
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
      actions: read
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 1
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
ci/review.sh
#!/usr/bin/env bash
# Headless review of the current branch (not run in this video: it needs a signed-in claude CLI).
git diff main...HEAD | claude -p \
  "Review this diff of our data pipeline. Run the build and the data checks, list each problem and the fix." \
  --allowedTools "Bash(python build.py),Bash(python checks/run_checks.py)" \
  --output-format json > review.json
.gitignore
shop.duckdb
__pycache__/
Terminal - setup commands
git init -q -b main && git add -A && git commit -qm "Shop pipeline with Claude workflow"
git checkout -q -b add-refunds && cat > models/daily_revenue.sql <<'EOF'
select
    o.order_date,
    count(*)                            as orders,
    sum(o.amount)                       as revenue,
    coalesce(sum(p.refund_amount), 0)   as refunds
from orders o
left join payments p on p.order_id = o.order_id
where o.status = 'completed'
group by o.order_date
order by o.order_date
EOF
git commit -qam "Add refunds to daily revenue"

How Claude Code GitHub Actions works

You need three things: the Claude GitHub app on your repository, an API key or token saved as a secret, and a workflow file.

THE PIECES

Claude Code GitHub Actions

  • The Claude GitHub app on your repository
  • A secret: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN
  • A workflow in .github/workflows/
  • @claude in a comment = interactive mode
  • A prompt in the workflow = automation mode

Then anyone with write access types @claude in a pull request comment, and Claude answers on the pull request. That is interactive mode. If you add a prompt to the workflow, it runs without a mention. That is automation mode. The action is built on the Claude Agent SDK.

Step 1: Quick setup with /install-github-app

The quickest setup starts inside Claude Code. Open Claude Code in your repository and type the slash command. The menu filters as you type.

Slash command - type this in Claude Code
/install-github-app

It installs the Claude GitHub app, saves the secret in this repository, and opens a pull request with the workflow file. You need admin rights on the repository and the GitHub command line tool signed in with gh auth login. It works for repositories on github.com.

For the secret, use ANTHROPIC_API_KEY with an API key from the Claude Console, or CLAUDE_CODE_OAUTH_TOKEN with a token from claude setup-token if you have a Pro, Max, Team or Enterprise plan.

Tip

Never put the key in the workflow file itself. It belongs in GitHub secrets.

Step 2: The workflow file, line by line

If you set it up by hand, you install the Claude GitHub app, add the secret, and copy this workflow into .github/workflows/. This is the example from the docs, and it is the same file that is in our repo.

.github/workflows/claude.yml

Interactive mode: answer @claude

Code
name: Claude Code
on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
jobs:
  claude:
    if: contains(github.event.comment.body, '@claude')
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
      actions: read
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 1
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

The on block runs the workflow when someone writes a new issue comment, or a new review comment on a pull request. The if line means the job only starts when the comment contains @claude.

The permissions let Claude change the code and write to pull requests and issues. Then there are two steps: check out the code, and run the Claude Code action, version 1. The API key comes from GitHub secrets, through the ANTHROPIC_API_KEY secret. It is never written in the file.

Tip

Grant only the permissions you need, and review this file like code whenever it changes.

Step 3: Automation mode with a prompt and claude_args

Now automation mode. Add a prompt to the action, and it runs on its own, without an @claude mention. A common approach for a data pipeline is to tell Claude exactly what to review and which commands to run.

AUTOMATION MODE

A prompt, max turns, allowed tools

Code
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          prompt: |
            Review this pull request for data problems.
            Run python build.py and python checks/run_checks.py,
            then list each problem with the file and the fix.
          claude_args: '--max-turns 5 --allowedTools "Bash(python build.py),Bash(python checks/run_checks.py)"'

claude_args passes options to Claude Code. --max-turns 5 puts a cap on how long it can work, which also caps the cost.

This part matters: a plain prompt gets no shell tools until you allow them. So we allow exactly two commands with --allowedTools: the build and the data checks. Run it on pull request events, and every new pull request gets the same review.

By default the result goes to the workflow run log. To post it on the pull request, ask for that in the prompt and allow a tool that can post a comment, such as mcp__github_inline_comment__create_inline_comment.

Step 4: The same review, run locally

Before you trust a review in CI, run the same review on your own machine: same branch, same commands. We are on the pull request branch add-refunds, and we ask Claude to review it the way the CI reviewer would.

Prompt - type this in Claude Code
Review this branch like the CI reviewer: diff against main, run python build.py and python checks/run_checks.py, write review.md

First Claude looks at what changed: the branch name, a diff summary, then the full diff of the model.

Terminal - the command
git branch --show-current && git diff --stat main...HEAD
Output
add-refunds
 models/daily_revenue.sql | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)
Terminal - the command
git diff main...HEAD -- models/daily_revenue.sql
Output
diff --git a/models/daily_revenue.sql b/models/daily_revenue.sql
index 56e2659..d628a9d 100644
--- a/models/daily_revenue.sql
+++ b/models/daily_revenue.sql
@@ -1,8 +1,10 @@
 select
     o.order_date,
-    count(*)        as orders,
-    sum(o.amount)   as revenue
+    count(*)                            as orders,
+    sum(o.amount)                       as revenue,
+    coalesce(sum(p.refund_amount), 0)   as refunds
 from orders o
+left join payments p on p.order_id = o.order_id
 where o.status = 'completed'
 group by o.order_date
 order by o.order_date

One file changed, models/daily_revenue.sql, with 4 insertions and 2 deletions. The change adds a left join payments and sums refund_amount as refunds. Then Claude runs the build and the data checks, exactly the two commands we allowed in the workflow.

Terminal - the command
python build.py
Output
built customer_orders   12 rows
built daily_revenue      4 rows
Terminal - the command
python checks/run_checks.py
Output (exit code 1, expected)
check                        result
orders: duplicate order_id   pass
orders: unknown customer     pass
payments: unknown order      pass
daily_revenue: revenue       FAIL  1299.5 vs 1089.5
daily_revenue: orders        FAIL  12 vs 11
2 of 5 checks failed

2 of 5 checks fail, and the script exits with code 1. The model says revenue is 1299.5, but completed orders add up to 1089.5. And it counts 12 orders instead of 11. Claude looks for the cause with a query for orders that have more than one payment row.

Terminal - the command
duckdb shop.duckdb -c "SELECT order_id, count(*) AS payment_rows FROM payments GROUP BY order_id HAVING count(*) > 1"
Output
┌──────────┬──────────────┐
│ order_id │ payment_rows │
│  int64   │    int64     │
├──────────┼──────────────┤
│     1004 │            2 │
└──────────┴──────────────┘

Order 1004 has 2 payment rows, so the join counts that order twice. The difference, 1299.5 minus 1089.5, is 210, which is exactly the amount of order 1004. That is a classic join fan-out. Claude writes the review to a file with the cause and the fix.

review.md
## Review: add-refunds

**Verdict: changes needed** (1 blocking issue)

1. **Join fan-out in `models/daily_revenue.sql`** (blocking)
   Order 1004 has two payment rows (payments 3 and 12), so
   `left join payments` counts it twice on 2026-10-02.
   The checks fail: revenue 1299.5 vs 1089.5, orders 12 vs 11.
   Fix: sum refunds per order in a CTE, then join one row per order.

2. Everything else passes: no duplicate orders, no unknown customers,
   no payments for unknown orders.

This is the same kind of comment you would get on the pull request: a verdict, the blocking issue with the numbers, and the fix.

Step 5: Fix the bug and re-run the checks

Now let's fix it, and prove the fix with the same checks.

Prompt - type this in Claude Code
Apply the fix from review.md and run the checks again

Claude sums the refunds per order in a CTE first and joins that, so every order is one row again. Then it runs the build and the checks again, and queries the model.

models/daily_revenue.sql
with refunds as (
    select order_id, sum(refund_amount) as refund_amount
    from payments
    group by order_id
)
select
    o.order_date,
    count(*)                            as orders,
    sum(o.amount)                       as revenue,
    coalesce(sum(r.refund_amount), 0)   as refunds
from orders o
left join refunds r on r.order_id = o.order_id
where o.status = 'completed'
group by o.order_date
order by o.order_date
Terminal - the command
python build.py && python checks/run_checks.py
Output
built customer_orders   12 rows
built daily_revenue      4 rows
check                        result
orders: duplicate order_id   pass
orders: unknown customer     pass
payments: unknown order      pass
daily_revenue: revenue       pass
daily_revenue: orders        pass
0 of 5 checks failed
Terminal - the command
duckdb shop.duckdb -c "SELECT * FROM daily_revenue"
Output
┌────────────┬────────┬─────────┬─────────┐
│ order_date │ orders │ revenue │ refunds │
│    date    │ int64  │ double  │ double  │
├────────────┼────────┼─────────┼─────────┤
│ 2026-10-01 │      2 │   195.5 │     0.0 │
│ 2026-10-02 │      3 │  344.25 │    30.0 │
│ 2026-10-03 │      3 │  302.75 │    10.0 │
│ 2026-10-04 │      3 │   247.0 │     0.0 │
└────────────┴────────┴─────────┴─────────┘

0 of 5 checks fail. The daily revenue table now adds up: 2026-10-02 has 3 orders and 344.25 in revenue, with 30.0 in refunds. Then Claude commits the fix to the same branch.

Terminal - the command
git commit -qam "Fix join fan-out in daily_revenue" && git log --oneline main..HEAD
Output
91891d9 Fix join fan-out in daily_revenue
dd5db15 Add refunds to daily revenue

The branch now has two commits on top of main: dd5db15 with the refunds change and 91891d9 with the fix. Push it, mention @claude on the pull request again, and Claude reviews the new code.

Tip

Put these data checks in your repository, so the same commands run on your laptop and in CI. Then a review is something you can repeat, not an opinion.

Step 6: Headless claude -p and CI safety

You can also run a review from any script or CI system with claude -p. That is headless mode, and it is what ci/review.sh from the starting files does. It pipes in the diff, gives the prompt, and allows only the build and the checks. In current versions a -p run starts in manual mode, so anything you did not allow is denied.

--output-format json gives you a result you can parse, and the exit code is 0 on success. We did not run this script here, because it needs a signed-in claude CLI.

For CI, the docs recommend --bare. It skips hooks, skills, MCP servers and CLAUDE.md, and it needs an API key.

And keep it safe. Use GitHub secrets for keys, grant minimal workflow permissions, cap turns and job time, and always review what Claude changes before you merge.

That is Claude Code in CI: the workflow from the docs, @claude on a pull request, automation mode with a prompt, and the same review run locally with real checks that caught a real join bug. In the next part, the last one in this series, we build an end to end data project with Claude Code, from a raw CSV file to a dashboard.

Key points to remember

  • Claude GitHub app + secret + workflow
  • @claude = interactive mode
  • prompt + claude_args = automation mode
  • --allowedTools: only the commands it needs
  • Same review locally: diff, build, checks
  • Headless claude -p (--bare in CI)

The full script

This bash script writes every file Claude wrote, in the same order, and runs every command, so you can reproduce the results without Claude.

Show the full script (every file and every command, in order)
Full script - bash, run it in an empty folder
#!/usr/bin/env bash
# Claude for Data Engineers #9: Claude Code in CI/CD: Review Data Pipeline Pull Requests with GitHub Actions
# Rebuilds the demo project without Claude: writes every file Claude wrote, in the same order,
# and runs every command from the video. Run it in an empty folder.
# Tools used in the video: Python 3.13, DuckDB 2.0.0, pandas 3.0.5, PySpark 4.3.0 (local mode, Java 21),
# dbt-core 1.12.6 with dbt-duckdb 1.11.0, PostgreSQL 16. Install the ones this project uses first.
mkdir -p shop-pipeline && cd shop-pipeline

# ---------- starting files ----------
mkdir -p data
cat > data/customers.csv <<'CLAUDE_DE_EOF'
customer_id,name,country,signup_date
101,Ava Brooks,US,2026-08-02
102,Liam Chen,CA,2026-08-05
103,Noah Patel,US,2026-08-11
104,Mia Garcia,MX,2026-08-19
105,Zoe Martin,UK,2026-09-01
106,Omar Khan,US,2026-09-03
CLAUDE_DE_EOF
mkdir -p data
cat > data/orders.csv <<'CLAUDE_DE_EOF'
order_id,customer_id,order_date,status,amount
1001,101,2026-10-01,completed,120.00
1002,102,2026-10-01,completed,75.50
1003,103,2026-10-01,cancelled,40.00
1004,101,2026-10-02,completed,210.00
1005,104,2026-10-02,completed,35.25
1006,105,2026-10-02,completed,99.00
1007,106,2026-10-03,completed,150.00
1008,102,2026-10-03,completed,64.75
1009,103,2026-10-03,completed,88.00
1010,104,2026-10-04,completed,45.00
1011,105,2026-10-04,completed,130.00
1012,106,2026-10-04,completed,72.00
CLAUDE_DE_EOF
mkdir -p data
cat > data/payments.csv <<'CLAUDE_DE_EOF'
payment_id,order_id,paid_at,amount,refund_amount
1,1001,2026-10-01,120.00,0.00
2,1002,2026-10-01,75.50,0.00
3,1004,2026-10-02,150.00,30.00
4,1005,2026-10-02,35.25,0.00
5,1006,2026-10-02,99.00,0.00
6,1007,2026-10-03,150.00,0.00
7,1008,2026-10-03,64.75,10.00
8,1009,2026-10-03,88.00,0.00
9,1010,2026-10-04,45.00,0.00
10,1011,2026-10-04,130.00,0.00
11,1012,2026-10-04,72.00,0.00
12,1004,2026-10-02,60.00,0.00
CLAUDE_DE_EOF
cat > build.py <<'CLAUDE_DE_EOF'
import pathlib
import duckdb

con = duckdb.connect("shop.duckdb")
for table in ("customers", "orders", "payments"):
    con.execute(f"CREATE OR REPLACE TABLE {table} AS SELECT * FROM read_csv('data/{table}.csv', header = true)")

for f in sorted(pathlib.Path("models").glob("*.sql")):
    con.execute(f"CREATE OR REPLACE VIEW {f.stem} AS " + f.read_text())
    rows = con.execute(f"SELECT count(*) FROM {f.stem}").fetchone()[0]
    print(f"built {f.stem:<16} {rows:>3} rows")
CLAUDE_DE_EOF
mkdir -p models
cat > models/daily_revenue.sql <<'CLAUDE_DE_EOF'
select
    o.order_date,
    count(*)        as orders,
    sum(o.amount)   as revenue
from orders o
where o.status = 'completed'
group by o.order_date
order by o.order_date
CLAUDE_DE_EOF
mkdir -p models
cat > models/customer_orders.sql <<'CLAUDE_DE_EOF'
select
    o.order_id,
    o.order_date,
    o.amount,
    c.customer_id,
    c.name,
    c.country
from orders o
join customers c on c.customer_id = o.customer_id
CLAUDE_DE_EOF
mkdir -p checks
cat > checks/run_checks.py <<'CLAUDE_DE_EOF'
"""Data checks for the shop pipeline. Exit code 1 when any check fails."""
import sys

import duckdb

con = duckdb.connect("shop.duckdb", read_only=True)
COMPLETED = "from orders where status = 'completed'"
CHECKS = {
    "orders: duplicate order_id": "select order_id from orders group by order_id having count(*) > 1",
    "orders: unknown customer": "select order_id from orders where customer_id not in (select customer_id from customers)",
    "payments: unknown order": "select payment_id from payments where order_id not in (select order_id from orders)",
    "daily_revenue: revenue": f"select m || ' vs ' || o from (select (select sum(revenue) from daily_revenue) m, (select sum(amount) {COMPLETED}) o) where m <> o",
    "daily_revenue: orders": f"select m || ' vs ' || o from (select (select sum(orders) from daily_revenue) m, (select count(*) {COMPLETED}) o) where m <> o",
}
print(f"{'check':<28} result")
failed = 0
for name, sql in CHECKS.items():
    bad = [str(r[0]) for r in con.execute(sql).fetchall()]
    failed += bool(bad)
    print(f"{name:<28} " + ("FAIL  " + ", ".join(bad) if bad else "pass"))
print(f"{failed} of {len(CHECKS)} checks failed")
sys.exit(1 if failed else 0)
CLAUDE_DE_EOF
mkdir -p .github/workflows
cat > .github/workflows/claude.yml <<'CLAUDE_DE_EOF'
name: Claude Code
on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
jobs:
  claude:
    if: contains(github.event.comment.body, '@claude')
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
      actions: read
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 1
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_DE_EOF
mkdir -p ci
cat > ci/review.sh <<'CLAUDE_DE_EOF'
#!/usr/bin/env bash
# Headless review of the current branch (not run in this video: it needs a signed-in claude CLI).
git diff main...HEAD | claude -p \
  "Review this diff of our data pipeline. Run the build and the data checks, list each problem and the fix." \
  --allowedTools "Bash(python build.py),Bash(python checks/run_checks.py)" \
  --output-format json > review.json
CLAUDE_DE_EOF
cat > .gitignore <<'CLAUDE_DE_EOF'
shop.duckdb
__pycache__/
CLAUDE_DE_EOF

# ---------- setup ----------
git init -q -b main && git add -A && git commit -qm "Shop pipeline with Claude workflow"
git checkout -q -b add-refunds && cat > models/daily_revenue.sql <<'EOF'
select
    o.order_date,
    count(*)                            as orders,
    sum(o.amount)                       as revenue,
    coalesce(sum(p.refund_amount), 0)   as refunds
from orders o
left join payments p on p.order_id = o.order_id
where o.status = 'completed'
group by o.order_date
order by o.order_date
EOF
git commit -qam "Add refunds to daily revenue"

# ---------- step 1: What the pull request changes: the diff against main ----------
git branch --show-current && git diff --stat main...HEAD
git diff main...HEAD -- models/daily_revenue.sql

# ---------- step 2: Run the build and the data checks, like the reviewer in CI would ----------
python build.py
python checks/run_checks.py   # expected to fail

# ---------- step 3: Find the cause: order 1004 has two payment rows ----------
duckdb shop.duckdb -c "SELECT order_id, count(*) AS payment_rows FROM payments GROUP BY order_id HAVING count(*) > 1"

# ---------- step 4: The review Claude writes ----------
cat > review.md <<'CLAUDE_DE_EOF'
## Review: add-refunds

**Verdict: changes needed** (1 blocking issue)

1. **Join fan-out in `models/daily_revenue.sql`** (blocking)
   Order 1004 has two payment rows (payments 3 and 12), so
   `left join payments` counts it twice on 2026-10-02.
   The checks fail: revenue 1299.5 vs 1089.5, orders 12 vs 11.
   Fix: sum refunds per order in a CTE, then join one row per order.

2. Everything else passes: no duplicate orders, no unknown customers,
   no payments for unknown orders.
CLAUDE_DE_EOF

# ---------- step 5: Fix: aggregate refunds per order before the join, then run the checks again ----------
mkdir -p models
cat > models/daily_revenue.sql <<'CLAUDE_DE_EOF'
with refunds as (
    select order_id, sum(refund_amount) as refund_amount
    from payments
    group by order_id
)
select
    o.order_date,
    count(*)                            as orders,
    sum(o.amount)                       as revenue,
    coalesce(sum(r.refund_amount), 0)   as refunds
from orders o
left join refunds r on r.order_id = o.order_id
where o.status = 'completed'
group by o.order_date
order by o.order_date
CLAUDE_DE_EOF
python build.py && python checks/run_checks.py
duckdb shop.duckdb -c "SELECT * FROM daily_revenue"

# ---------- step 6: Commit the fix to the pull request branch ----------
git commit -qam "Fix join fan-out in daily_revenue" && git log --oneline main..HEAD
Next in this series

Part 10: End-to-End Data Project with Claude Code: Raw CSV to Dashboard

The project and its data are invented for teaching. Every file on this page is the real file from the demo project, and every output is the real output of the command above it, captured when the video was made (each step ran twice in a fresh folder and the outputs were compared). The Claude screens in the video are a simplified recreation for teaching. Always review what Claude changes and runs. Claude plans, features and limits are current as of November 2026; check the official Claude and Claude Code documentation before you rely on them. Part of the Claude for Data Engineers series on TechBrothersIT.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.