Skip to main content

10 posts tagged with "Airflow"

View all tags

Airflow trigger no dag_run_id? duplicate logical_date 409

Β· 5 min read

Calling the Airflow trigger API, the request goes out cleanly and your logs say "triggered" β€” and later you discover the DAG never ran; the task list is empty.

Encountered this while building AI Ops β€” LLM-powered analytics that surfaces market trends, user behavior, and sales data for precise operational strategy. The platform's backfill script triggers an analysis DAG day by day; one repeated date parameter and the downstream silently misses an entire batch.

Symptom: the API "succeeded", the DAG never ran​

The trigger request completes without raising, the caller's log line says triggered β€” but Airflow shows no corresponding run and nothing executes. What makes this nasty: it never surfaces in the triggerer's errors, only in the downstream question "why is this day missing?". If you searched for "Airflow API trigger no effect", "dagRun not created" or "trigger dag silently fails", same problem.

Root cause: Airflow enforces (dag_id, logical_date) uniqueness on triggers​

Within one DAG, logical_date cannot repeat (dag_run_id is a unique primary key too). Triggering again with an existing logical_date is rejected outright β€” verified on Airflow 3:

POST /api/v2/dags/{dag_id}/dagRuns
HTTP 409
{"detail": {"reason": "Unique constraint violation", ...}}

The response is a 4xx β€” the status code and body both say "not triggered". The failure lives in how the caller consumes that response. The three most common misses:

  • No status-code check: no network exception means "success", and the 4xx body gets discarded;
  • "Got a response" treated as success: resp.json() parsed fine, therefore triggered β€” nobody looks for dag_run_id;
  • Error text lost in log noise: the 409 detail is a constraint-violation blob that no grep keyword matches, so nobody reads it twice.

The fix: a fresh logical_date per trigger, assert dag_run_id in the response​

Two things, both mandatory. On the trigger side, backfills use a distinct logical_date per day (naturally increasing):

from datetime import date, timedelta

for i in range(days):
ld = (date(2026, 1, 1) + timedelta(days=i)).isoformat()
trigger(dag_id, logical_date=ld)

On the caller side, make "response body contains dag_run_id" the only success criterion, with status codes as a secondary signal:

import requests

def trigger(dag_id: str, logical_date: str) -> str:
resp = requests.post(
f"{BASE}/api/v2/dags/{dag_id}/dagRuns",
headers=auth_headers(),
json={"logical_date": logical_date},
)
body = resp.json()
run_id = body.get("dag_run_id")
if not run_id:
# 409 = a run already exists for this logical_date; other 4xx/5xx are failures too
raise TriggerFailed(f"{resp.status_code}: {body}")
return run_id

The beauty of this criterion is that it's status-code agnostic: whatever the server answers, a body without dag_run_id means the trigger did not happen β€” one check covers every failure shape.

Boundary cases: the rejection is sometimes exactly what you want​

Worth stating the other side: if your logical_date is the business date, the unique constraint is your idempotency guard. Backfilling the same day twice β€” the second trigger gets a 409 and no duplicate run is created. In that scenario the move is not "make the trigger succeed" but "clear and rerun the existing run". Don't mix the two:

ScenarioCorrect move
Backfill: one run per dayFresh logical_date per trigger
Rerun the same business dayDon't re-trigger; clear + rerun the existing run
Deciding whether a trigger happenedOnly trust dag_run_id in the body; status code secondary

Also: manual triggers without a logical_date get a manual__<timestamp> run_id and corresponding logical_date from Airflow β€” inherently collision-free. The ones who hit this are callers constructing their own logical_date values.

Watch out

  • Put the dag_run_id check inside your trigger wrapper β€” scattered call sites guarantee one of them skips it, and that one silently drops a batch.
  • Alert on status-code classes, not message keywords β€” the 409 detail is constraint-error text that no grep pattern reliably matches.
  • Business-date logical_dates are good practice: clear semantics plus a free idempotency guard.

FAQ​

What happens if I trigger the same Airflow DAG run twice via the API?​

It gets rejected by a unique constraint: re-triggering with an existing logical_date returned HTTP 409 (Unique constraint violation) with no dag_run_id in the body, and the DAG never ran. Callers that only log the request miss the failure entirely.

Why was my Airflow dagRun triggered but nothing ran?​

The most common cause is a duplicate logical_date β€” Airflow enforces (dag_id, logical_date) uniqueness and answers 409, which an unchecked caller never notices. Rule: a trigger only succeeded if the response body contains a dag_run_id.

Can logical_date repeat in Airflow?​

Not within the same DAG β€” (dag_id, logical_date) carries a unique constraint and dag_run_id is a primary key. The rejection is by design and doubles as an idempotency guard for business-date-based triggers.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow DAG gone but metadata stays? Delete order matters

Β· 5 min read

The .py file is gone from the codebase and deployed β€” yet the DAG still sits in Airflow's list. You clean the metadata tables by hand, and minutes later the rows are back.

Encountered this while building AI Ops β€” LLM-powered analytics that surfaces market trends, user behavior, and sales data for precise operational strategy. Retiring old analysis types in the DAG pipeline left zombie entries piling up in the list page.

Symptom: the file is gone, the metadata refuses to leave​

Two symptoms, depending on the order you did things:

  • File deleted first, tables checked after: rows for the dag_id remain in dag, serialized_dag, dag_code and dag_version, and the UI list still shows the DAG;
  • Tables cleaned first, file deleted after: stranger still β€” freshly DELETEd rows reappear within minutes. The DAG "resurrects".

If you searched for "Airflow delete DAG still shows in UI" or "dag metadata comes back", same story.

Root cause: dag-processor registers whatever it finds; reserialize never cleans​

The resurrection is designed behavior, not a haunting:

  • dag-processor periodically scans the DAG directory. As long as the .py file exists, the scan parses it and re-registers metadata rows β€” which is exactly why cleaning tables before deleting the file resurrects the DAG: at clean time the file was still there, and the next scan rebuilt everything.
  • airflow dags reserialize only registers upward. It serializes "files currently in the directory" into the tables, but never removes rows whose files have disappeared β€” run reserialize after deleting a file and the leftovers don't budge.

So the order of "delete file" vs "clean metadata" is inherently one-directional: while the file exists, cleaning is futile; once it's gone, one clean is final.

The fix: delete file β†’ clean tables in FK order β†’ reserialize to verify​

Three steps, order not negotiable:

  1. Delete the .py file first. In production the DAG directory is a volume mount β€” sync via git pull on the host rather than deleting inside the container:
ssh <host> "cd /root/workspace/ai_dag && git pull"
# the DAG directory (mounted as /opt/airflow/dags) no longer has the .py
  1. Clean the metadata in foreign-key order (task_instance goes with dag_run via cascade):
DELETE FROM dag_run        WHERE dag_id = 'old_dag';      -- cascades task_instance
DELETE FROM serialized_dag WHERE dag_id = 'old_dag';
DELETE FROM dag_code WHERE dag_id = 'old_dag';
DELETE FROM dag_version WHERE dag_id = 'old_dag';
DELETE FROM dag WHERE dag_id = 'old_dag';
  1. Run reserialize as the verification: after airflow dags reserialize, query the dag table β€” if the dag_id doesn't reappear, the cleanup is final.

The whole thing takes under a minute; with the file gone from the directory, dag-processor's scans have nothing to re-register.

Boundary cases​

  • Make sure no scheduler/processor is mid-parse of that DAG before cleaning β€” avoid racing the registrar; off-peak windows keep it simple.
  • If run history matters as evidence, export it with SELECT first β€” run history is the only record of "what actually happened"; once deleted it's gone.
  • Same dag_id moving directories/platform folders follows the same logic: make the old file disappear from the directory first, then do the metadata surgery.
  • Metadata schemas differ across Airflow versions (dag_code/dag_version are relatively recent) β€” \d your own tables and foreign keys before running DELETEs; don't copy table names blind.

Watch out

  • The order is an iron rule: cleaning tables before deleting the file = guaranteed resurrection. Put "delete the file" at step one and everything after is downhill.
  • Direct SQL against a production metadata database is high-risk: WHERE must pin the exact dag_id β€” check affected row counts inside a transaction before committing.
  • The UI's "delete" button (newer versions) goes through an API path with different behavior from manual table cleaning; this flow targets scenarios needing precise control of what gets removed.

FAQ​

Why does a deleted DAG still show in the Airflow UI?​

Its metadata rows remain in dag, serialized_dag, dag_code and dag_version, and the UI renders from tables. reserialize only upserts files that exist β€” it never removes rows for vanished files, so the tables need a manual DELETE in foreign-key order.

Why did the metadata I cleaned come back?​

The DAG file was still on disk when the tables were cleaned: dag-processor periodically scans the DAG directory and re-registers any file it finds. Delete the file first (git pull into the mounted directory), then clean tables, then reserialize to confirm.

What is the right order to fully remove an Airflow DAG?​

  1. delete the .py file and sync the DAG directory; 2) DELETE in foreign-key order: dag_run (cascades task_instance) β†’ serialized_dag β†’ dag_code β†’ dag_version β†’ dag; 3) run airflow dags reserialize and confirm the dag table stays clean.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow 3 password reset not working? It reads a JSON file

Β· 5 min read

After updating a user password on Airflow 3, requests to /auth/token with the new password still return 401 β€” while the password hash in the database verifiably holds the new value.

Encountered this while building AI Ops β€” LLM-powered analytics that surfaces market trends, user behavior, and sales data for precise operational strategy. The platform's DAG trigger chain rides on this Airflow JWT auth, so a stuck password rotation left the pipeline authorization dangling.

Symptom: password changed, /auth/token still answers with the old one​

The new password gets 401 from /auth/token while the old one still gets 201 β€” every modification step "succeeded", yet the password in effect never changed. This particular rotation was incident response: the old credential had leaked, so every minute of "changed but not effective" was exposure.

The first reflex is the official CLI:

$ airflow users reset-password -u apiuser -p <new>
AttributeError: 'AirflowSecurityManagerV2' object has no attribute 'find_user'

The error points into the FAB (Flask-AppBuilder) auth stack, which smells like a 3.x version bug. CLI broken? Fine β€” bypass it and update the database directly. That detour set up an even deeper trap.

Root cause: SimpleAuthManager never reads the database β€” the password lives in passwords.json​

This deployment's auth manager is not the FAB the error implies, but SimpleAuthManager β€” one command reveals it:

airflow config get-value core auth_manager
# airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager

Three layers, and the symptom explains itself:

  • The CLI error is a red herring. The airflow users CLI family belongs to the FAB auth stack; on a SimpleAuthManager deployment its code path crashes with AttributeError. The AirflowSecurityManagerV2 class in the message does exist (as a leftover component), but it has nothing to do with the manager actually handling auth.
  • ab_user is a leftover table. Deployments upgraded from Airflow 2.x carry FAB's user tables. Writing directly β€” UPDATE ab_user SET password=... with a werkzeug generate_password_hash scrypt hash β€” returned rowcount=1, and check_password_hash confirmed the stored hash matched the new password. Yet /auth/token kept answering 401: the table genuinely changed; the auth flow simply never reads it.
  • The real source is a JSON file. SimpleAuthManager takes user passwords from the file pointed to by AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE β€” a flat mapping:
{
"apiuser": "<password>",
"admin": "<password>"
}

The username-to-role mapping is declared separately via AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS (e.g. apiuser:admin,admin:admin). The first move in incidents like this should be asking "which manager owns auth?", not patching the symptom "auth is broken".

The fix: edit passwords.json, then force-recreate the container​

Three steps:

  1. Edit the host-side passwords.json (the source file mounted into the container):
python3 - <<'EOF'
import json
d = json.load(open('/root/workspace/ai_dag/deploy/passwords.json'))
d['apiuser'] = '<new password>'
json.dump(d, open('/root/workspace/ai_dag/deploy/passwords.json', 'w'), indent=2)
EOF
  1. Recreate the api-server container. This step is not optional β€” the password file is read only at startup, so editing it does nothing to the running process:
cd /root/workspace/ai_dag/deploy
docker compose up -d --force-recreate airflow-api-server
  1. Once the service is up, verify in both directions β€” the new password must pass AND the old one must fail; both assertions matter:
# health: back to 200 within ~30s of the recreate
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/api/v2/monitor/health

# new password β†’ expect 201
curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/auth/token \
-H 'Content-Type: application/json' \
-d '{"username":"apiuser","password":"<new>"}'

# old password β†’ expect 401
curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/auth/token \
-H 'Content-Type: application/json' \
-d '{"username":"apiuser","password":"<old>"}'

On this incident the results were 201 for the new password, 401 for the old β€” rotation closed. Verifying only "the new password works" without "the old one fails" is the most common loose end in credential rotation.

Watch out

  • Identify the auth manager before touching anything: the output of airflow config get-value core auth_manager decides where the password lives β€” FAB keeps it in the database, SimpleAuthManager in a file. Completely different paths.
  • Editing the password file demands a container recreate: the file is read only at startup, and docker compose restart won't reload it.
  • Services depending on that auth must pick up the new password too and restart β€” otherwise the pipeline sits in a window where the old password 401s and the new one isn't wired in.
  • Recreating the api-server costs seconds to half a minute of downtime β€” do it off-peak, and poll health until it returns 200 before verifying.

FAQ​

Why does the reset-password command fail after upgrading to Airflow 3?​

On 3.x deployments running SimpleAuthManager, the FAB airflow users CLI is gone for good β€” it crashes with AttributeError: 'AirflowSecurityManagerV2' object has no attribute 'find_user' (verified). Run airflow config get-value core auth_manager first to see which manager actually handles auth.

Where does SimpleAuthManager store user passwords?​

In a passwords.json file (flat JSON like {"username": "password"}) pointed to by AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE. The file is read only at container startup β€” after editing it you must run docker compose up -d --force-recreate airflow-api-server.

Why doesn't updating the ab_user table change the password?​

ab_user belongs to the FAB auth manager. On deployments migrated to SimpleAuthManager it is leftover data the auth flow never reads β€” we wrote a scrypt hash with rowcount=1 and /auth/token still returned 401.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Container Logs Filling Your Server Disk? docker system df 'Reclaimable' Lies

Β· 6 min read

The alert email: production server root partition at 81% (30G/40G, past the 80% red line). First instinct: check docker system df β€” and there it is, images at RECLAIMABLE 100% (7), apparently a quick win. Except all 7 images are running; following the hint with docker image prune -a would be a production incident.

Encountered this while building AI Analytics β€” an LLM-powered analytics platform that surfaces market trends, user behavior, and sales data; the disk in question belongs to the Docker server running its Airflow data pipeline.

TL;DR​

Past a disk red line, don't trust docker system df RECLAIMABLE β€” it's an estimate of "space with no container reference", and active images get flagged 100% anyway. The right move: sudo du -xh -d1 / layer by layer. This incident's three invisible hogs were none of them business data: task logs accumulating in container writable layers (no log volume in the Dockerfile), package-manager caches (npm + pnpm, ~4G combined), and a backup script that never pruned (full clones every run). Fixes: delete caches, reclaim writable layers with force-recreate, add TTL pruning to the backup script β€” 81% back down to 64%.

Symptoms​

$ df -h /
Filesystem Size Used Use% Mounted on
/dev/vda1 40G 30G 81% /

$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 7 7 4.2GB 100% (7) ← all running
Containers 5 5 810MB 0%

Acting on docker system df (clean images) is a dead end β€” RECLAIMABLE says 100% but ACTIVE says 7/7. Where the space actually went, docker system df never shows:

sudo du -xh -d1 / | sort -rh | head
# selected output:
# 2.7G /root/.npm ← npm cache
# 1.1G /root/.local/share/pnpm ← pnpm store
# 857M /root/backups-git ← backup clones, never pruned
# (hidden in overlay2: airflow scheduler writable layer 468M + dag-processor 257M)

Root Cause​

Three kinds of consumption, all invisible from the "business data" perspective.

Writable layers eating logs. Airflow task logs are written inside the containers with no external volume β€” scheduler writable layer 468M, dag-processor 257M, ~780M accumulated in two weeks, monotonically growing. Image layers never change; the writable layer does. docker system df buries it in the Containers SIZE total (810M), where it has no presence.

Package-manager caches only grow. On a server with frequent deploys/builds, ~/.npm (2.7G) and the pnpm store (1.1G) pile up indefinitely; nobody ever cleans them.

A backup script that never prunes. The backup script clones the repo worktree in full and pushes to GitHub every run β€” old clone directories are never removed, so 857M is mostly historical duplicates. Sneakier: orphan clones whose branch never pushed successfully can't just be deleted; verify first.

And docker system df RECLAIMABLE is a statistical estimate of "space not referenced by a running container" β€” active images can display 100% reclaimable (all 7 of ours did). It's a false-positive generator, not a cleanup guide.

Solution​

Step 1: Locate with du, layer by layer β€” look before deleting​

sudo du -xh -d1 / | sort -rh | head        # root partition, level by level
sudo du -xh -d1 /var/lib/docker | sort -rh | head # drill into Docker's dir
docker ps -as --format "table {{.Names}}\t{{.Size}}" # per-container writable layer

du -x stays on one filesystem, avoiding /proc, /sys noise and overlay confusion; docker ps -as SIZE exposes each container's writable layer β€” the key command for the "logs written inside the container" family.

Step 2: Delete caches outright​

npm cache clean --force        # or simply rm -rf ~/.npm/_cacache
pnpm store prune # removes only unreferenced packages

~5.1G reclaimed, zero risk β€” caches re-download on demand.

Step 3: Reclaim writable layers with force-recreate​

docker compose up -d --force-recreate   # writable layer goes away with the old container

~780M reclaimed here. Two preconditions: pick a low-traffic window (services restart), and rescue anything valuable first β€” e.g. docker cp the task logs out, or they vanish with the container.

Step 4: TTL for backups, prevent recurrence​

# keep backup dirs for 7 days, prune older ones
find /root/backups-git -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +

Add the pruning to the tail of the backup script (deployed in our github-backup-push.sh); before deleting, check for orphan clones whose branch never pushed (git ls-remote to compare) and confirm no unique commits β€” our 325M of orphans were removed only after verification.

After cleanup: 81% β†’ 64%, with caches + pruning + writable layers together reclaiming ~6.7G.

Notes

  • docker system df RECLAIMABLE β‰  deletable: active images can be flagged 100% (all 7 of ours were). Decide from du + docker ps -as measurements.
  • force-recreate restarts services and destroys writable layers β€” docker cp out any logs you need first. The real fix is an external log volume with retention rotation; writable-layer recycling is this incident's stopgap.
  • Always check for orphan backups before deleting: branches that never pushed successfully β€” verify with git ls-remote first.
  • Pair disk red-line alerts with the locating command: the first action on alert should be du -xh -d1 /, not guessing.
  • For the earlier incident on the same server (disk 93% + CPU 160%), see Debugging a 2-core/7G Docker Server Resource Black Hole.

FAQ​

docker system df shows 100% RECLAIMABLE β€” can I just delete?​

No. RECLAIMABLE estimates "space with no container reference", and running active images can still be flagged 100% reclaimable β€” all 7 of ours were. It answers "how much is theoretically unreferenced", not "what can be deleted". Measure first with du -xh -d1 and docker ps -as.

What's the difference between docker ps SIZE and docker system df SIZE?​

docker ps -as SIZE is the per-container writable layer; docker system df is the category-level aggregation over images/containers/volumes/cache. Logs accumulating inside a container only ever appear in the writable layer (visible via docker ps -as), never in any image size β€” which is exactly why "images look small but disk keeps growing".

How do I clean up a Docker server running out of disk?​

Three categories: package-manager caches deleted outright (npm cache clean --force, pnpm store prune), zero risk; container writable layers reclaimed via docker compose up -d --force-recreate (service restart; rescue logs first); backups, logs, and clone directories put on TTL pruning to prevent recurrence. Before any deletion, confirm the hogs with du -xh -d1 /.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Data Pipeline Snapshot Slots Misaligned? Empty Segments Collapse Positions

Β· 5 min read

Auditing a production task's decision snapshot, we found stage 5's feature data sitting in array slot 3 instead of the documented slot 4. Downstream consumers and the detail drawer read by "segment number minus one" β€” and were silently getting the previous stage's output.

Encountered this while building AI Analytics β€” an LLM-powered analytics platform that surfaces market trends, user behavior, and sales data; the snapshot is what the pipeline leaves behind for frontend rendering and post-hoc audit.

TL;DR​

The pipeline appends each stage's output into the snapshot array in execution order, and an if rows.empty: skip guard drops 0-row segments entirely, shifting every later segment forward. The static mapping "segment number βˆ’ 1 = slot" breaks at will, and which segments are empty depends on runtime state β€” slots differ per run. Two fixes: resolve slots at read time by in-row keys (recommended), or keep placeholders for empty segments so slots stay constant.

Symptoms​

The assembly logic looks like this:

snapshot = {"features": [], "rule_output": []}
for seg in segments: # stages 1..5 run in order
df = execute_sql(seg.sql)
if not df.empty: # 0-row segments dropped here
snapshot["features"].append(df.to_dict("records"))

The design assumption was "stage 5 β†’ slot 4". Production snapshot audit found stage 5's features in slot 3:

all segments populated:   β‘ β†’0  β‘‘β†’1  β‘’β†’2  β‘£β†’3  β‘€β†’4   βœ“ matches assumption
stage 4 empty, dropped: β‘ β†’0 β‘‘β†’1 β‘’β†’2 β‘€β†’3 βœ— shifted
stages 3+4 empty: β‘ β†’0 β‘‘β†’1 β‘€β†’2 βœ— shifted again

The same code version produces completely different slot layouts depending on shop and permissions β€” an empty whitelist table drops stage 4; a disabled feature flag drops stage 3. Slots drift with runtime state.

Root Cause​

Positional addressing collided with sparse assembly. The snapshot array is a runtime product of concatenating "segments that produced output" β€” a sparsely-filled collection compressed. Consumers hard-coding "segment βˆ’ 1" implicitly assume every segment always yields at least one row. That assumption shatters in three routine situations: empty whitelist tables, disabled feature flags, naturally empty business data. 0-row segments are the norm, not the exception.

Deeper down, the emptiness guard itself (if not df.empty) is not wrong β€” wrong is the contract's implicit premise. The design doc says "slot = segment βˆ’ 1" but nobody declared it as an explicit contract. Every position-addressing consumer inherits an unacknowledged, unmaintained assumption.

Solution​

Let every row carry a segment identifier key; consumers resolve positions at read time with no static mapping:

def locate_segment(features: list, seg_key: str) -> dict:
for row in features:
if seg_key in row: # row carries its own identity
return row
raise KeyError(f"segment '{seg_key}' missing in snapshot")

Slot drift becomes irrelevant β€” you look for "the segment whose keys look like this", not "element N". The one requirement: all consumers go through this single resolution entry point (write it into the processor docstring and the consumer contract: no hard-coded slots, ever).

Option B: keep placeholders for empty segments on the write side​

If downstream can't change yet, keep "slot = segment number" constant at assembly time:

snapshot["features"].append(
df.to_dict("records") if not df.empty else {"__empty__": True}
)

The cost: placeholder objects appear in the snapshot and every consumer must handle them. Fine as a transition; converge on Option A long-term.

Step 3: contract tests over multiple runtime states​

Build snapshot fixtures for "all populated / one empty / several empty" and assert consumers parse all three identically. Testing only the all-full scenario tests nothing.

Notes

  • Any positional mapping in a spec must explicitly declare the addressing scheme (by key / by ID) and note that hard-coded indices are forbidden; implicit assumptions get broken by some runtime state eventually.
  • Empty-skip guards are the most common source of compression β€” a sibling case of silent data loss is Airflow PostgresHook truncating multi-statement SQL to the first result: again "no error, quietly less data".
  • Before rolling out consumer changes, regression-test with all three runtime-state fixtures; validating only the all-populated state misses every misalignment path.

FAQ​

How do you handle schema drift in data engineering?​

Replace positional contracts with key-based ones: address snapshots, messages, and interfaces by field name or segment identifier, never array index. When upstream changes without notice (empty segments skipped, fields added or removed), key-addressed consumers at worst raise "not found" β€” they never silently read the wrong data.

What is the difference between schema drift and schema evolution?​

Evolution is explicitly managed versioning: add a field, cut a release, migrate consumers. Drift is passive: upstream changes and downstream quietly misaligns. The slot shifting in this post is textbook drift β€” nobody touched the contract; the data shape changed.

How do I detect this kind of slot misalignment in a pipeline?​

Two layers: contract tests covering multiple runtime states (all populated / one empty / several empty) asserting identical consumer parsing; and periodic production snapshot audits checking each slot's content against its declared segment. "Content and position disagree" is drift.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow dagRun Trigger Fails Silently? The logical_date Unique Constraint

Β· 5 min read

While triggering the same DAG repeatedly via the Airflow REST API for a staged rollout check, the request came back 4xx with no dag_run_id in the body β€” the DAG never actually ran β€” yet the script treated it as success.

Encountered this while building AI Analytics β€” LLM-powered analytics that surfaces market trends, user behavior, and sales data for precise operations strategy. The staged rollout of the ad-decision pipeline needed to trigger the same analysis repeatedly on Airflow for comparison, and some triggers were failing silently.

TL;DR​

Airflow enforces a unique constraint on each DAG's logical_date (dag_run_id must also be unique). POSTing /dags/{dag_id}/dagRuns with a logical_date that already exists gets rejected with a 4xx, and the response body contains no dag_run_id. If you only check the HTTP status code and don't inspect the returned dag_run_id, you'll mistake the rejection for success. Fix: use a distinct logical_date (and dag_run_id) on every trigger.

Symptoms​

To run a comparison test, the same DAG was triggered repeatedly with a fixed date 2026-01-01:

$ curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dag_run_id": "manual-run-1",
"logical_date": "2026-01-01T00:00:00Z"
}'
# First time: returns a normal dag_run object with dag_run_id βœ…

$ curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dag_run_id": "manual-run-2",
"logical_date": "2026-01-01T00:00:00Z" # ⚠️ same logical_date
}'
# Second time: returns an error object, no dag_run_id ❌
{
"detail": "...",
"status": 400,
"title": "Bad Request",
"type": "https://airflow.apache.org/docs/apache-airflow/2/stable-rest-api-ref.html#/default/Error"
}

If the caller only checks "is it 2xx" and stops there, or parses the JSON without verifying that dag_run_id exists, the second failure is silently swallowed β€” no error in the logs, no run in the Airflow UI.

Root Cause​

Airflow uses dag_run_id as the primary key for each run and maintains uniqueness on (dag_id, logical_date) in the metadata DB's dag_run table. logical_date is the "logical time" of a run β€” the scheduler uses it to decide whether a given schedule slot has already executed. Once a run with some logical_date exists for a DAG, triggering again with the same value is rejected to prevent duplicate execution.

The catch is that this failure is a 4xx with an error JSON, not a connection error or a 5xx. Many scripts only do a coarse response.status_code == 200 check, or grab the JSON and read fields without verifying dag_run_id is present β€” so "creation rejected" reads as "creation succeeded".

Solution​

Core idea: use a distinct logical_date (and dag_run_id) on every trigger. For replay / rollout-comparison scenarios, just append a counter to the date:

# Each iteration uses a different logical_date (2026-01-01 / 02 / 03 …)
for i in 1 2 3; do
curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"dag_run_id\": \"manual-run-$i\",
\"logical_date\": \"2026-01-0${i}T00:00:00Z\"
}"
done

Even better, use an incrementing timestamp so logical_date and dag_run_id never collide. More importantly: always verify the dag_run_id field in the response β€” treat it as the only proof the trigger actually succeeded:

import requests

def trigger_dag(dag_id: str, logical_date: str, conf: dict | None = None) -> str:
resp = requests.post(
f"{AIRFLOW}/api/v2/dags/{dag_id}/dagRuns",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
json={"dag_run_id": f"manual-{logical_date}", "logical_date": logical_date, "conf": conf or {}},
)
# ❌ Not enough: status-only check lets 4xx slip through as success
# resp.raise_for_status()
data = resp.json()
# βœ… Correct: only count it as created if dag_run_id is present
if "dag_run_id" not in data:
raise RuntimeError(f"Trigger failed: {resp.status_code} {data}")
return data["dag_run_id"]

# A distinct logical_date each time makes repeated triggers safe
for i in range(1, 4):
trigger_dag("my_dag", f"2026-01-0{i}T00:00:00Z")

Keep dag_run_id unique too β€” it's the primary key, and duplicates are rejected outright. A "prefix + logical_date" convention is common: unique, and easy to spot in the UI.

FAQ​

How do you trigger a DAG with the Airflow REST API?​

POST /api/v2/dags/{dag_id}/dagRuns with a body containing at least dag_run_id and logical_date (plus an optional conf for parameters). Both must be unique within the same DAG, or Airflow returns 4xx. In code, prefer the TriggerDagRunOperator, which also generates a unique run id internally.

Why does triggering the same DAG repeatedly fail in Airflow?​

Because Airflow maintains a unique constraint on (dag_id, logical_date) in the dag_run table, and dag_run_id itself is a primary key. Duplicate logical_date or dag_run_id values are rejected with 4xx. For replays or staged comparisons, give each trigger a fresh logical_date (or incrementing timestamp).

Caveats

  • Verify dag_run_id, not just the status code: a 4xx with an error JSON is Airflow's normal way of saying "creation rejected" β€” a status-only check easily misreads failure as success.
  • Use a past logical_date: a future date is treated as a scheduled run and won't execute immediately; use a past date to run it now.
  • API version differences: Airflow 2.x uses /api/v2/dags/{dag_id}/dagRuns; 3.x adjusts paths and fields β€” always check the REST API reference for your version when upgrading.
  • Prefer the CLI's --logical-date for replays: airflow dags trigger accepts a date, but the logical_date uniqueness constraint still applies β€” a repeated date fails the same way.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow DAG Still in the List After Deletion? Metadata Not Cleaned + Correct Order

Β· 6 min read

After deleting a DAG's .py file in Airflow to retire it, the dag_id still hangs around in the Web UI list and the database; even stranger β€” if you clear the metadata first and delete the file second, the just-cleared rows "come back to life."

Encountered this while building AI Ops β€” an LLM-powered analytics pipeline where retiring an old report DAG required cleaning its metadata too, otherwise the UI list and scheduled scans stayed polluted by residual rows.

TL;DR​

Airflow's dag-processor periodically scans the DAG folder and re-registers DAGs, and airflow dags reserialize doesn't purge "file-already-deleted" orphan rows β€” so just deleting the .py file won't make the dag_id vanish from the UI or DB. Conversely, clearing metadata before deleting the file lets the processor re-register the cleared rows on its next scan ("revival"). Correct order: β‘ delete the file first so the processor stops registering β†’ β‘‘SQL DELETE in foreign-key order β†’ β‘’run airflow dags reserialize to verify.

Symptoms​

Retiring the shop_report_aggregation DAG β€” after deleting its .py file:

$ ls /opt/airflow/project/airflow_dags/shop_report_aggregation.py
ls: cannot access '.../shop_report_aggregation.py': No such file or directory

$ # but it's still in the database
$ docker exec cclhub-db psql -U airflow -d airflow -c \
"SELECT dag_id, is_paused, is_active FROM dag WHERE dag_id='shop_report_aggregation';"
dag_id | is_paused | is_active
--------------------------+-----------+-----------
shop_report_aggregation | f | t ← still there

It's not just the dag table β€” the matching rows in serialized_dag, dag_code, and dag_version are all still there, so the Web UI keeps showing this "deleted" DAG.

Worse is the reverse order β€” clear metadata first, delete file second:

T0  DELETE FROM dag WHERE dag_id='shop_report_aggregation';   ← cleared
T1 (.py file not deleted yet)
T2 dag-processor scan fires; file exists, dag table has no row β†’ re-registers
T3 SELECT ... FROM dag WHERE dag_id='shop_report_aggregation'; ← it's back (revival)

Root Cause​

Two mechanisms stack up:

1. dag-processor scans and re-registers periodically. Airflow's dag-processor (part of the Scheduler) scans dags_folder on processor_poll_interval (default ~5 min), parses each .py file, and upserts into the metadata tables (dag, serialized_dag, dag_version). As long as the file exists, the next scan rewrites those rows. That's the direct source of "revival" β€” you clear the row, the file is still there, and the processor re-registers it as a new DAG.

2. reserialize ignores "file-gone" orphan rows. airflow dags reserialize re-serializes existing DAG files and refreshes serialized_dag; it does not delete orphan dag rows whose files have vanished. And airflow dags cleanup only purges expired dag_run history by default β€” it also leaves the dag / serialized_dag / dag_code / dag_version metadata tables alone. So after you delete the file, the metadata rows become orphans nobody cleans.

β”Œβ”€ dag-processor ──────────────────────────────┐
β”‚ scans dags_folder β”‚
β”‚ β”œβ”€ file present β†’ upsert dag / serialized... β”‚ ← source of revival
β”‚ └─ file absent β†’ skip, no row deletion β”‚ ← orphan residue
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Conclusion: to actually remove the metadata, you must give the processor no file to register (delete the file first), then manually clean the residual rows.

Solution​

Step 1: Delete the file first​

Make the .py file disappear from the DAG folder so dag-processor stops registering it.

# In production this is usually synced to the volume-mounted DAG folder
# /opt/airflow/project/airflow_dags/ via git pull
git pull # removes shop_report_aggregation.py from the repo and the folder

# or delete directly (after confirming nothing depends on it)
rm /opt/airflow/project/airflow_dags/shop_report_aggregation.py

Step 2: Clean metadata in foreign-key order​

DELETE in foreign-key dependency order to avoid constraint violations. Deleting dag_run CASCADEs to task_instance:

BEGIN;

-- 1. run history (CASCADEs to task_instance)
DELETE FROM dag_run WHERE dag_id = 'shop_report_aggregation';

-- 2. serialized DAG
DELETE FROM serialized_dag WHERE dag_id = 'shop_report_aggregation';

-- 3. version
DELETE FROM dag_version WHERE dag_id = 'shop_report_aggregation';

-- 4. dag main table
DELETE FROM dag WHERE dag_id = 'shop_report_aggregation';

-- 5. dag_code is keyed by source hash; multiple DAGs may share the same code;
-- only delete hashes no longer referenced by any serialized_dag
DELETE FROM dag_code
WHERE dag_hash NOT IN (SELECT dag_hash FROM serialized_dag);

COMMIT;

Step 3: Verify​

airflow dags reserialize

# confirm the dag row is not rebuilt
docker exec cclhub-db psql -U airflow -d airflow -c \
"SELECT count(*) FROM dag WHERE dag_id='shop_report_aggregation';"
# count
# -------
# 0 βœ…

After reserialize, dag / serialized_dag / dag_code / dag_version are all 0 for that dag_id, and the next processor scan doesn't rebuild them β€” the cleanup is stable.

As a side note, on the same pipeline, pandas NaN crashing XCom serialization is another pitfall worth bookmarking.

Notes​

Notes

  • dag_code is shared by source hash: multiple DAGs can reference the same source hash, so before deleting, always use the orphan check (dag_hash NOT IN (SELECT dag_hash FROM serialized_dag)) β€” never delete by dag_id, because this table has no dag_id column at all.
  • Don't expect airflow dags cleanup to clear metadata: it only purges expired dag_run rows (controlled by max_active_runs / retention) and leaves dag / serialized_dag / dag_code / dag_version untouched. Cleaning metadata means hand-written SQL.
  • Waiting one scan cycle after deleting the file is safer: in an extreme race, a processor scan could land in the window between your file deletion and your metadata cleanup. In practice the "delete file β†’ clean metadata β†’ reserialize to verify" order is enough; rerun reserialize once more if needed.
  • Check for downstream dependencies before retiring a DAG: other DAGs may wait on it via ExternalTaskSensor or trigger it via TriggerDagRunOperator. grep for dag_id references first.

FAQ​

Why does a DAG still show in Airflow after deleting its .py file?​

Deleting the file doesn't clean the database. Rows in dag / serialized_dag / dag_code / dag_version still exist, and the Web UI reads those tables to render the list, so the deleted DAG keeps showing. Airflow has no built-in command to purge these orphan rows automatically; you must SQL DELETE them manually in foreign-key order.

How do I completely delete an Airflow DAG and all its metadata?​

Three steps: 1) delete the .py file so dag-processor stops registering it; 2) SQL DELETE in foreign-key order (dag_run β†’ serialized_dag β†’ dag_version β†’ dag β†’ orphan dag_code); 3) run airflow dags reserialize, then query the dag table to confirm the dag_id row count stays at 0 and isn't rebuilt.

What's the correct order to clean Airflow DAG metadata, and why not clear metadata before deleting the file?​

Delete the file first, then clean metadata. If you reverse it, the .py file still exists, so dag-processor re-registers the cleared dag row on its next scan β€” the metadata "comes back to life." Only by making the file vanish first (so the processor has nothing to register) and then cleaning the residual rows can you fully retire the DAG.


CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow XCom Throws 'Out of range float values are not JSON compliant'? Blame pandas NaN

Β· 7 min read

When an Airflow task calls ti.xcom_push() to pass pandas-processed results downstream, the task crashes outright β€” ValueError: Out of range float values are not JSON compliant: nan β€” and the app's custom logs table shows no error at all.

Encountered this while building AI Ops β€” an LLM-powered analytics pipeline where Airflow DAGs pull SQL data, process it with pandas, and pass results between tasks via XCom.

TL;DR​

XCom serializes with JSON under the hood, and Airflow calls json.dumps(..., allow_nan=False) to follow the JSON spec strictly β€” which has no NaN / Infinity whatsoever. The moment a float NaN (converted from SQL NULL by pandas) enters the data passed to xcom_push, serialization throws ValueError. Fix: recursively walk the data before push and convert NaN / Β±Inf to None (JSON null).

Symptoms​

A quarterly report DAG failed, but the symptom was baffling β€” in the app's custom logs table, steps 1–4 for that trace were all fine, "analysis done" even logged twice (a retry), then it cut off: step 5 missing, with no error row at all:

trace=91c126c3
β”œβ”€ step 1 SQL fetch βœ…
β”œβ”€ step 2 pandas process βœ…
β”œβ”€ step 3 rule judge βœ…
β”œβ”€ step 4 LLM analysis done βœ… ← retried after this
└─ step 5 XCom push result ❌ ← missing, no error record

The real traceback was only in the Airflow task log:

# inside container /opt/airflow/logs/dag_id=ai_analysis_v2/run_id=.../task_id=analyze_results/attempt=N.log
ValueError: Out of range float values are not JSON compliant: nan
File ".../ai_analysis_tasks.py", line 142, in analyze_results
ti.xcom_push(key='sql_metadata', value=result)

The crash landed exactly on ti.xcom_push β€” the instant the task pushed results into XCom.

Root Cause​

Three layers stack up, all required:

1. The JSON spec has no NaN / Infinity. RFC 8259 only allows finite numeric literals. Python's json.dumps will happily emit bare NaN and Infinity by default, but those are Python-specific extensions, not valid JSON β€” any strict parser (Airflow included) rejects them.

2. Airflow XCom serializes with allow_nan=False. XCom's default JSON serializer explicitly disables NaN tolerance, so encountering NaN throws ValueError: Out of range float values are not JSON compliant instead of silently emitting invalid JSON.

3. pandas reads SQL NULL as NaN. pandas.read_sql returns float('nan') for SQL NULL columns. Once such a column flows through computation and to_dict('records') into the result object, NaN hitches a ride into xcom_push:

import pandas as pd

# A SQL NULL cell β†’ pandas reads it as NaN
df = pd.DataFrame({"ad_roi": [1.2, None, 0.8]})
records = df.to_dict("records")
# [{'ad_roi': 1.2}, {'ad_roi': nan}, {'ad_roi': 0.8}] ← nan slipped in

# downstream task crashes on push
ti.xcom_push(key="result", value=records)
# ValueError: Out of range float values are not JSON compliant: nan

This stayed latent for a long time because the data usually had values in those columns; it only surfaced when a client had zero ad spend for an entire quarter and ad_roi came back NULL across the board β€” the first time NaN entered the XCom path at scale.

Why no error in the logs table? Because the crash happens during XCom serialization, outside the task function's try/except β€” the exception bubbles straight up to the Airflow scheduler and only lands in Airflow's own task log. The app's custom logs table catch never gets a chance to record it. That's what makes this failure so confusing: it looks "silent."

Solution​

Scrub all NaN / Β±Inf from the data before it enters XCom.

1. Write a pure recursive cleaner​

import math

def json_safe_value(obj):
"""
Recursively convert NaN / +Inf / -Inf to None so the data is
strictly JSON-serializable. Handles dict / list / tuple / scalar;
unknown types pass through unchanged.
"""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if isinstance(obj, dict):
return {k: json_safe_value(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [json_safe_value(v) for v in obj]
return obj

Why not df.fillna(None)? Because fillna(None) on numeric columns is unstable across pandas versions and dtypes β€” sometimes it coerces the dtype instead of nulling values. It also only handles DataFrames, not floats already nested inside dicts/lists after to_dict. Recursive cleaning at the "data is now native Python structures" layer is the most robust fallback.

2. Centralize the guard before push​

The worry-free approach is to hang the cleanup on the single chokepoint all xcom_push calls go through, rather than remembering to call it at every push site:

def push_safe(ti, key, value):
"""Clean NaN/Inf before XCom push to prevent serialization crashes."""
ti.xcom_push(key=key, value=json_safe_value(value))

# inside the task
push_safe(ti, "sql_metadata", result)
push_safe(ti, "processor_output", processor_result)

3. Fix the "silent failure" observability gap​

Fixing serialization alone isn't enough β€” the gap where exceptions outside try/except never reach the app's logs table must be closed too. Attach a failure decorator that logs the top-level exception to your table before re-raising:

import functools
import logging

logger = logging.getLogger(__name__)

def log_task_failure(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception:
logger.error("task %s failed", fn.__name__, exc_info=True)
# write the traceback into the app's custom logs table here
raise
return wrapper

@log_task_failure
def analyze_results(**context):
...

Now if another exception slips outside a catch, the app's logs table still gets an error row β€” no more "silent failure."

After the fix, rerunning the same conf: DAG all green, DB write success, and the formerly-NaN ad_roi lands as null in the database; downstream is happy.

On the same Airflow analytics pipeline, this isn't the only way data silently misbehaves β€” PostgresHook silently dropping multi-statement SQL results is another classic.

Notes​

Notes

  • json.dumps defaults to allow_nan=True, which is a footgun: it silently emits bare NaN / Infinity as invalid JSON, and the crash only shows up when a strict parser downstream (Airflow XCom, JS JSON.parse) reads it. Always pass allow_nan=False explicitly when serializing data that crosses a process boundary, to surface the problem early.
  • Β±Infinity bites too: float('inf') / float('-inf') are excluded from the JSON spec just like NaN; json_safe_value must handle them together.
  • XCom has more than one serializer: Airflow also supports binary object serialization, which can store arbitrary Python objects, but such XCom values are unreadable, not version-portable, and carry deserialization security risk. In production, stick with JSON and clean the data.
  • Triage heuristic: when a logs-table trace cuts off with no error row, go straight to the Airflow task log (inside the container at /opt/airflow/logs/dag_id=.../task_id=.../) for the traceback β€” "no app log" does not mean "no error."

FAQ​

How to fix Airflow "Out of range float values are not JSON compliant"?​

XCom serializes with json.dumps(allow_nan=False) and ran into NaN / Infinity, which the JSON spec does not allow. The usual root cause is pandas reading a SQL NULL into float('nan') that then flows into xcom_push. Fix it by recursively converting NaN / Β±Inf to None (JSON null) before push, centralized in a pure json_safe_value helper.

Why does my Airflow task fail but my custom logs table has no error?​

If the exception happens during XCom serialization, outside the task function's try/except, it only bubbles up to the Airflow scheduler and lands in the Airflow task log (inside the container at /opt/airflow/logs/). The app's custom logs table catch never sees it, so it looks like a "silent failure." To triage, read the Airflow task log traceback directly instead of only checking app logs.

Can Airflow XCom store pandas NaN directly?​

No. XCom defaults to JSON serialization, and the JSON spec only has finite numbers β€” no NaN / Infinity. The right fix is to convert NaN to None (JSON null) before push. Switching to binary object serialization sidesteps the type limit but produces unreadable, non-portable values with deserialization security risk; not recommended for production.


CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Airflow PostgresHook Multi-Statement SQL Silently Drops Results? Split by Semicolon and Execute One by One

Β· 6 min read

When an Airflow DAG reads a .sql template file as a single string and passes it to PostgresHook.get_pandas_df(), prior SELECT results are silently dropped β€” the DAG reports "SQL query returned no results", but copying the same SQL into psql returns data normally.

Encountered this while building AI Analytics β€” an LLM-powered analytics pipeline where an Airflow DAG reads multi-query report templates from .sql files and executes them.

TL;DR​

PostgresHook.get_pandas_df(sql) internally calls pandas.read_sql(sql, conn) β†’ psycopg2 cursor.execute(sql). When sql is a single string with multiple ;-separated SELECTs, the DBAPI only exposes the cursor of the last result set β€” prior query results are silently dropped with no error. Fix: split by top-level semicolons into a list[str] and call get_pandas_df per statement, or pass the list directly so DbApiHook runs them sequentially.

Symptom​

The DAG task executing shop_monthly_overview.sql reports "SQL query returned no results":

sql_count = 1   ← template clearly contains 4 queries
result = "❌ SQL query returned no results"

But the same SQL pasted into psql against the same database with the same parameters returns data for all 4 SELECTs.

Reproduction​

Verify get_pandas_df behavior with multi-statement SQL directly inside the Airflow container:

from airflow.providers.postgres.hooks.postgres import PostgresHook

hook = PostgresHook(postgres_conn_id="postgres_default")

# Three SELECTs concatenated into one string
sql = "SELECT 1 AS a; SELECT 2 AS b; SELECT 99 AS c WHERE 1=0;"

df = hook.get_pandas_df(sql)
print(df.columns.tolist()) # ['c'] ← only got the last statement's columns
print(df) # Empty ← the last statement itself returns 0 rows

Expected three result sets, got only the last one (SELECT 99 ... WHERE 1=0, 0 rows). The first two completely disappear with no error or warning.

Root Cause​

The call chain is PostgresHook.get_pandas_df β†’ DbApiHook.get_pandas_df β†’ pandas.io.sql.read_sql β†’ psycopg2 cursor.execute(sql).

The DBAPI protocol (PEP 249) allows execute to accept a string with multiple ;-separated statements. PostgreSQL executes all of them, but the cursor only exposes the last result set β€” this is inherent PostgreSQL wire protocol behavior, not an Airflow or pandas bug.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SELECT 1; ← executed, result set 1 dropped at once β”‚
β”‚ SELECT 2; ← executed, result set 2 dropped at once β”‚
β”‚ SELECT 99 WHERE 1=0; ← executed, result set 3 exposed β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
pandas.read_sql only fetches result set 3

The root cause in our code is task_execute_sql reading the entire .sql file as a single string and passing it to get_pandas_df:

# ❌ Problematic code
sql_text = open(sql_path).read() # full text with 4 SELECTs
df = pg_hook.get_pandas_df(sql_text) # only gets the last result

Why does psql return data? Because the psql frontend actively iterates through all result sets and prints them one by one, while a DBAPI cursor does not.

Solution​

Suitable for .sql template files β€” they contain comments, quotes, and multiple queries that need robust splitting.

def split_sql_statements(sql: str) -> list:
"""
Split SQL by top-level semicolons, correctly handling:
- Semicolons inside single-quoted strings ('a;b' is not split)
- SQL-standard '' escape ('it''s' is not split)
- Semicolons inside -- line comments (-- note; not split is not split)
"""
statements = []
buf = []
i, n = 0, len(sql)
in_quote = False

while i < n:
ch = sql[i]

# Inside a single-quoted string
if in_quote:
buf.append(ch)
if ch == "'":
# '' = literal single quote, does not end the string
if i + 1 < n and sql[i + 1] == "'":
buf.append(sql[i + 1])
i += 2
continue
in_quote = False
i += 1
continue

# Top level
if ch == "'":
in_quote = True
buf.append(ch)
elif ch == '-' and i + 1 < n and sql[i + 1] == '-':
# Line comment, swallow to end of line (; inside is not a split point)
while i < n and sql[i] != '\n':
buf.append(sql[i])
i += 1
continue
elif ch == ';':
stmt = ''.join(buf).strip()
if stmt:
statements.append(stmt)
buf = []
i += 1
continue
else:
buf.append(ch)
i += 1

# Trailing block without a final semicolon
stmt = ''.join(buf).strip()
if stmt:
statements.append(stmt)

return statements


# Caller
sql_text = open(sql_path).read()
statements = split_sql_statements(sql_text)

# Execute one by one, collect all results
all_results = []
for idx, stmt in enumerate(statements, start=1):
df = pg_hook.get_pandas_df(stmt)
if not df.empty:
all_results.append({
"sql_index": idx,
"sql": stmt,
"data": df.to_dict("records"),
"columns": df.columns.tolist(),
"row_count": len(df),
})

Option B: Pass a list directly to DbApiHook​

Airflow's DbApiHook.run and get_records accept list[str] and execute sequentially β€” but get_pandas_df return behavior in list mode is inconsistent across providers. For production, Option A gives you full control.

Why not sqlparse.split?​

Community answers often recommend sqlparse.split(sqlparse.format(sql, strip_comments=True)), but strip_comments=True discards comments. If your downstream processor depends on metadata in comments (e.g. -- dimension: shop), you lose context. A hand-rolled splitter preserves the original comment text and gives you control.

Caveats​

Caveats

  • Do not use sql.split(';') β€” it will mis-cut semicolons inside quoted strings like WHERE name = 'a;b', and inside -- comment; line comments
  • split_sql_statements only handles single-quoted strings and -- line comments; if your SQL uses /* block comments */ or dollar-quoted strings ($$...$$), extend the splitter
  • After the fix, the semantics of sql_index for downstream processors change (1-based sequential index); audit all df.iloc[sql_index] style usages
  • If your SQL is program-generated rather than file-read, the safer pattern is to build a list at generation time rather than split later
  • A related trap: if you've also hit issues with SQL expressions being silently parameterized in Drizzle ORM, see Drizzle sql template mixing parameterized values with SQL expressions β€” same family of "the framework did a transformation you didn't expect" bugs

FAQ​

How do I execute multiple SQL statements in Airflow PostgresHook?​

Pass list[str] instead of a single string. DbApiHook.get_pandas_df and run accept sql as a list and execute sequentially; a single string with semicolon-separated statements causes psycopg2 to return only the last result set. For production, split yourself and call per-statement so you control result aggregation and sql_index.

Why does get_pandas_df only return the last result for multi-statement SQL?​

pandas.io.sql.read_sql calls psycopg2 cursor.execute with the full string; the DBAPI protocol only exposes the cursor of the last result set for multi-statement execution, and prior SELECT results are dropped by the server immediately, with no error or warning. psql returns data because its frontend actively iterates all result sets, while a DBAPI cursor does not.

How do I split SQL by semicolon safely with comments and quotes?​

Scan character by character and split only at top-level semicolons outside single-quoted strings and -- line comments. Single-quote literals use the SQL-standard '' escape; do not use str.split(';'), it will mis-cut semicolons inside comments and strings. If you use sqlparse.split, note that strip_comments=True discards the original comment text.


CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me