8.3.8 Engineering practices: Python and SQL quality, testing, observability, AI assistants¶
Production-grade, tested, maintainable Python and SQL pipelines; Terraform, CI/CD and git; AI coding assistants; metrics for load duration, throughput, stability and cost; testing, documentation and handover of patterns.
1. What "production-grade pipeline" means (checklist)¶
| Property | Concretely |
|---|---|
| Idempotent | Rerun-safe: partition overwrite / MERGE, logical dates |
| Tested | Unit tests (logic), data tests (quality), integration tests (end-to-end on a dev dataset) |
| Observable | Logs, metrics (duration, rows, bytes, freshness), alerts with owners |
| Configurable | No hard-coded projects or dates; config per environment |
| Version-controlled | Everything in git: SQL, DAGs, Terraform, LookML |
| Reviewed and automated | PRs, CI checks, automated deploy |
| Documented | README, data contracts, runbook ("what to do when this alert fires") |
| Secure | Service account per pipeline, secrets in Secret Manager, no keys in code |
| Cost-aware | Labels, guardrails, efficient SQL |
| Recoverable | Backfill procedure, time travel, replayable raw data |
2. Testing data pipelines: the pyramid¶
▲ fewer, slower, more realistic
╱ ╲ End-to-end: run the pipeline on a dev/CI dataset, reconcile outputs
╱───╲
╱ ╲ Data tests: dbt tests / Dataform assertions / quality checks on real data
╱───────╲
╱ ╲ Unit tests: pure Python functions, SQL logic on small fixtures, DAG integrity
╱───────────╲ many, fast, cheap
Unit tests: Python¶
# src/transforms.py
from decimal import Decimal
def net_amount(gross: Decimal, discount: Decimal, vat_rate: Decimal) -> Decimal:
"""Net sales excluding VAT after discount."""
if gross < 0 or discount < 0:
raise ValueError("amounts must be non-negative")
return ((gross - discount) / (1 + vat_rate)).quantize(Decimal("0.01"))
# tests/test_transforms.py
from decimal import Decimal
import pytest
from src.transforms import net_amount
@pytest.mark.parametrize("gross,discount,vat,expected", [
(Decimal("125.50"), Decimal("0"), Decimal("0.255"), Decimal("100.00")), # Finnish general VAT 25.5%
(Decimal("125.50"), Decimal("25.10"), Decimal("0.255"), Decimal("80.00")),
])
def test_net_amount(gross, discount, vat, expected):
assert net_amount(gross, discount, vat) == expected
def test_negative_rejected():
with pytest.raises(ValueError):
net_amount(Decimal("-1"), Decimal("0"), Decimal("0.255"))
Unit tests: SQL logic¶
- dbt unit tests (dbt ≥ 1.8): give mocked input rows and expected output rows for a model.
- Dataform:
type: "test"with input fixtures. - Or a pytest harness that runs SQL against small temp tables in a CI dataset.
# dbt unit test
unit_tests:
- name: dedup_keeps_latest_version
model: stg_pos__order_lines
given:
- input: source('raw_pos', 'order_lines')
rows:
- {order_id: "A", line_no: 1, qty: 1, _source_ts: "2026-10-22 10:00:00"}
- {order_id: "A", line_no: 1, qty: 3, _source_ts: "2026-10-22 10:05:00"}
expect:
rows:
- {order_id: "A", line_no: 1, qty: 3}
Data tests (on real data, every run)¶
| Test | Catches |
|---|---|
unique on the grain key |
Duplicates from non-idempotent loads |
not_null on keys |
Broken joins, bad source data |
relationships |
Orphan facts (missing dimension rows) |
accepted_values |
New unexpected codes from the source |
| Freshness | Stalled ingestion |
| Volume anomaly | Partial loads (today 40% below the normal weekday) |
| Reconciliation with the source | Silent data loss |
Blocking vs. warning: critical tests (uniqueness, reconciliation of finance numbers) stop the pipeline before bad data reaches Looker. Others alert.
Data contracts (upstream protection)¶
- An agreement with source teams: schema, types, semantics, SLAs, change process.
- Enforced with dbt model contracts (
contract: {enforced: true}), schema checks in ingestion, and alerts on Datastream schema drift. - Most pipeline failures come from upstream changes. Data contracts and schema-drift alerts turn surprises into planned changes.
3. Python code quality for data engineering¶
| Practice | Tool |
|---|---|
| Formatting and linting | ruff (lint + format) or black + flake8 |
| Type hints + checking | mypy / pyright |
| SQL linting | sqlfluff (BigQuery dialect) |
| Dependency management | uv / poetry / pip-tools with pinned versions |
| Packaging shared code | An internal package (e.g. dataplatform-common) used by DAGs and Dataflow |
| Pre-commit hooks | pre-commit running ruff, sqlfluff, terraform fmt |
| Secrets | Secret Manager, never in code or Airflow Variables in plain text |
| Logging | Structured (JSON) logs with pipeline, run_id, rows, duration fields |
| Config | Per-environment YAML or env vars; no hard-coded project IDs |
Structure of a pipeline repo¶
data-platform/
├─ dags/ # Airflow DAGs + factory + configs/
├─ dataform/ or dbt/ # SQL models, tests
├─ dataflow/ # Beam pipelines + Flex template Dockerfiles
├─ src/common/ # shared Python (clients, logging, validation)
├─ terraform/ # modules + envs
├─ tests/ # pytest (unit, DAG integrity)
├─ .github/workflows/ or cloudbuild.yaml
├─ pyproject.toml
└─ README.md + docs/ # architecture, runbooks, onboarding
4. Git and CI/CD practices¶
- Trunk-based development with short-lived feature branches and PRs. Main is always deployable.
- PR template: what changed, how it was tested, data impact (backfill needed?), rollback.
- CODEOWNERS per area (platform owns
terraform/, domains own their models). - Environments: dev → test → prod. Promote via merge and approval, not manual copying.
- Data-specific CI: BigQuery dry runs, dbt/Dataform build in a PR-specific dataset (e.g.
ci_pr_123) with sampled or cloned data (table clones are cheap), then drop it after. - Deploy order for breaking changes: expand → migrate → contract (add the new column, backfill, switch consumers, then drop the old one).
5. Metrics: load duration, throughput, stability, cost¶
Define them as SLIs (what you measure) with SLOs (targets):
| Category | SLI | Example SLO | Source |
|---|---|---|---|
| Load duration | Nightly chain end-to-end time; per-DAG p50/p95 duration | Nightly chain done by 06:00 on 99% of days | Airflow metadata DB |
| Throughput | Rows/sec or GB/hour per pipeline; Datastream events/sec | ≥ X rows/s at peak | Job stats, Datastream metrics |
| Freshness | now - MAX(_source_ts) per mart |
Sales mart ≤ 15 min behind, 99% of the time | Scheduled query → metrics table |
| Stability | Task failure rate, retries, reruns needed, incidents per month | < 1% failed task runs; 0 manual reruns/week | Airflow metadata, incident log |
| Data quality | % of tests passing, reconciliation differences | 100% of blocking tests pass | dbt/Dataform results |
| Cost | BigQuery slot-hours / TB billed per pipeline per day; cost per 1M rows loaded | −30% vs. baseline | INFORMATION_SCHEMA.JOBS + labels + billing export |
How to implement it¶
- Labels on everything (
pipeline,domain,layer,env) for BigQuery jobs, Dataflow jobs and resources. - One ops dataset (
ops_metrics) with scheduled queries that collect:- Airflow run stats (from the Composer metadata DB export or the Airflow REST API)
INFORMATION_SCHEMA.JOBSaggregated by label- Freshness per mart
- Billing export
- A Looker dashboard "Platform health": trends before and after modernization. That's the proof for the business.
- Alerts in Cloud Monitoring on SLO breaches, routed to the owning team.
Baseline these metrics in the first week, so every change can be shown as a before/after number: runtime, failure rate, freshness and cost.
6. Documentation and handover¶
| Artifact | Content |
|---|---|
| Architecture overview | Diagram: sources → ingestion → layers → Looker; projects and IAM |
| Pattern docs | "How to onboard a source", "how to build an incremental model", "how to backfill" |
| Runbooks | Per alert: meaning, first checks, fixes, escalation |
| Data catalog | dbt docs / Dataform descriptions / Dataplex: table and column descriptions, owners |
| Decision log (ADRs) | Why Dataform over dbt, why merge mode, why these partitions |
Handover approach: show → pair → review → own. The internal team does wave 2 with review support and wave 3 alone.
7. AI coding assistants¶
Practical and responsible use:
- Where they fit: boilerplate (Terraform resources, DAG skeletons, test cases), SQL translation drafts (e.g. Spark SQL → BigQuery), regex and parsing, documentation drafts, explaining unfamiliar code, generating test data.
- How to keep quality:
- Treat the output as a junior colleague's PR: review, test, understand every line
- Tests and CI catch regressions regardless of who wrote the code
- Dry-run generated SQL and check the bytes scanned and the query plan
- Never paste secrets, credentials or customer PII into prompts; follow the company's AI policy
- Where they help most in an EDW modernization: migrating 300 DAGs to a factory pattern, translating SQL dialects, writing dbt/Dataform tests at scale, writing descriptions for the semantic layer (then reviewing them).
In short: AI assistants speed up boilerplate, tests and SQL translation, but everything goes through the same review and CI as human code. The productivity gain is real, but accountability stays with the engineer.