Blog / Parallax: How I Built a CI Tool That Reads Your SQL and Maps Every Downstream Dashboard Before Your PR Merges
18 min read

Parallax: How I Built a CI Tool That Reads Your SQL and Maps Every Downstream Dashboard Before Your PR Merges

A walk-through of how Parallax works internally: SQL AST diffing, in-memory DAG traversal, column-level lineage tracing, and risk-scored PR blocking for dbt. Zero database credentials required.

Every data team I have talked to has the same story. Someone edits a filter in a staging model. The PR looks fine. Tests pass. The code review approves it. Three days later, the CFO’s ARR dashboard is reporting 12% lower revenue than last quarter. The finance team opens a ticket. The on-call engineer pulls the model history, traces the lineage by hand, and eventually finds it: a WHERE status NOT IN ('returned', 'cancelled') that became WHERE status = 'delivered'. An innocent tightening that silently dropped every order that was pending or in transit. The filter was logically valid. But it was semantically wrong for the business.

This post is about Parallax, an open source CI tool that catches that class of problem before the PR merges. I will walk through how it works from the inside: the AST engine, the lineage graph, the risk scoring rules, and how the GitHub Action wires it all together. There is a live demo scenario built into the tool that you can run right now with no credentials, no dbt project, and no cloud.

GitHub: Parallax Repository

Example Testing Sandbox: Airflow & dbt Pipeline Sandbox

Quickstart: git clone https://github.com/Ramprasad273/parallax.git && cd parallax && pip install -e . | Run demo: parallax demo


The Problem That Motivated This

Data teams use dbt to define transformations as SQL files organized into layers: staging, intermediate, marts, reporting. Each layer builds on the one before it. At the top sit BI exposures: Looker dashboards, Tableau workbooks, reverse ETL feeds, ML feature stores.

The dependency chain looks like this:

raw source tables
    |
    v
staging models (stg_*)         <- engineers edit these most
    |
    v
intermediate models (int_*)    <- calculated metrics, joins
    |
    v
mart models (fct_*, dim_*)     <- wide tables for analysis
    |
    v
reporting models (rpt_*)       <- business-level aggregations
    |
    v
BI exposures                   <- CFO dashboards, board reports

When an engineer edits a staging model, the change propagates silently through every layer below it. Nothing in a standard CI pipeline tells you which downstream reports will produce different numbers. You find out when someone complains.

Parallax solves this by doing three things statically, without touching the database:

  1. Parse the changed SQL files into Abstract Syntax Trees and classify what changed semantically (filter tightened, column dropped, join type altered, calculation modified).
  2. Traverse the dbt dependency graph from the changed model to every downstream consumer, including BI exposures.
  3. Score the risk and decide whether to block the PR.

System Architecture

flowchart TD
    subgraph Input ["INPUT"]
        A["Git Diff\n(base branch vs PR HEAD)"]
        B["dbt manifest.json\n(compiled dependency graph)"]
    end

    subgraph AST ["AST SEMANTIC DIFF ENGINE\nparallax/core/ast_diff.py"]
        C["SQLGlot Parser\n(25+ SQL dialects)"]
        D["Predicate Differ\n(WHERE / HAVING / JOIN ON)"]
        E["Projection Differ\n(SELECT column list)"]
        F["Join Structure Differ\n(INNER vs LEFT vs FULL)"]
        G["ModelASTDiff\n(typed Pydantic model)"]
    end

    subgraph Lineage ["LINEAGE DAG ENGINE\nparallax/core/lineage.py"]
        H["DbtManifest\n(nodes, exposures, child_map)"]
        I["NetworkX DiGraph\n(in-memory, no DB roundtrip)"]
        J["Blast Radius Traversal\nnx.descendants()"]
        K["Column-Level Lineage\n(AST expression tracing)"]
        L["DownstreamNode list\nExposureNode list"]
    end

    subgraph Risk ["RISK ENGINE\nparallax/core/risk_engine.py"]
        M["8-Rule Severity Classifier\nCRITICAL / HIGH / MEDIUM / LOW"]
        N["Plain-English Summary\n(deterministic, no LLM)"]
        O["Remediation Checklist"]
        P["BlastRadiusReport\n(frozen Pydantic model)"]
    end

    subgraph Output ["OUTPUT SURFACES"]
        Q["Rich CLI Terminal\n(tables, trees, timers)"]
        R["GitHub PR Comment\n(pinned, deduplicated)"]
        S["Standalone HTML Report\n(offline, SVG DAG)"]
        T["JSON / Markdown\n(custom pipelines)"]
    end

    A --> C
    B --> H
    C --> D & E & F
    D & E & F --> G
    H --> I
    I --> J & K
    G --> J
    J --> L
    K --> L
    G & L --> M
    M --> N & O
    N & O --> P
    P --> Q & R & S & T

Each component is stateless. There are no database connections, no external API calls, and no LLM calls anywhere in the execution path. Every output is deterministic.


Why This Problem is Harder Than It Looks

The naive version of this tool is easy to build: parse the SQL, find the changed lines, print them. The hard parts are:

Semantic classification of predicates. A diff tool can tell you that WHERE status NOT IN ('returned', 'cancelled') changed to WHERE status = 'delivered'. It cannot tell you whether that tightened or loosened the filter without understanding set semantics. Parallax uses a deterministic heuristic rule engine for the most common predicate mutations: IN-list contraction/expansion, numeric threshold direction, comparison operator shifts, and negative-exclusion-to-strict-equality conversions. For compound OR expressions and arbitrary boolean algebra, Parallax treats the entire expression as a single unit and classifies the change conservatively as MUTATED_OPERATOR, falling back to risk scoring at the model level. Full predicate containment for arbitrary SQL is an NP-hard SMT problem (requiring Z3-class solvers) and is intentionally out of scope for a CI gate that must complete in under 2 seconds.

Multi-hop column tracing. Columns do not flow unchanged through dbt models. They get aliased, transformed, aggregated, and renamed at every layer. Tracing a dropped column through three hops of aliasing requires parsing and correlating the ASTs of every intermediate model. The lookup is subtle: column names are case-insensitive in most SQL dialects, aliases must be resolved, and derived expressions (like net_booked_amount * 1.1 as gross_amount) mean the upstream column name and the downstream column name are different strings.

Keeping it fast enough to be a CI gate. The tool runs on every PR that touches any SQL file. If it takes 30 seconds, engineers will disable it. The in-memory NetworkX graph, lazy column-lineage evaluation, and zero-API-call design are what keep it practical as a gate rather than an offline analysis tool.

Not crying wolf. A CI gate that fires on whitespace changes gets ignored within a week. Parallax evaluates whether a change is semantically equivalent to the original before reporting it. A formatting-only reformat that does not change the AST structure produces LOW risk and does not block anything.


How the AST Diff Engine Works

The entry point is parallax check. It calls git diff to get the list of changed .sql files, reads the old and new content, and passes each pair through ASTDiffEngine.diff_model().

Inside diff_model, SQLGlot parses both versions into expression trees. SQLGlot supports 25+ SQL dialects including Snowflake, BigQuery, Postgres, DuckDB, and Databricks. It handles Jinja template stripping transparently so dbt’s {{ ref('model') }} references do not confuse the parser.

The engine then runs three passes:

Pass 1: Predicate diffing

It collects all boolean conditions from WHERE, HAVING, and JOIN ON clauses using tree.find_all(exp.Where). For each condition it does a set difference between base and head. Conditions that exist in base but not head are deletions. Conditions that exist in head but not base are additions. For changed conditions (same column, different expression), it classifies the mutation:

  • TIGHTENED: the new predicate allows fewer rows than the old one. Example: NOT IN ('returned', 'cancelled') becomes = 'delivered'.
  • LOOSENED: the new predicate allows more rows. Example: IN ('a', 'b') becomes IN ('a', 'b', 'c').
  • DROPPED: a filter condition that existed before is entirely absent now.
  • MUTATED_OPERATOR: the comparison operator changed, for example > to >=.

For numeric comparisons, it extracts column names and literal values and compares them directly to determine direction:

old_comp = self._extract_col_and_num(old_node)   # ('amount', 100.0)
new_comp = self._extract_col_and_num(new_node)   # ('amount', 50.0)

# GT with lower threshold = LOOSENED (more rows pass)
if isinstance(old_node, (exp.GT, exp.GTE)) and new_val < old_val:
    return (PredicateDiffType.LOOSENED, ...)

Pass 2: Projection diffing

It builds a dictionary of column_name -> expression_sql for both the base and head SELECT lists. Then it computes:

  • Columns in base but not head: DROPPED
  • Columns in head but not base: ADDED
  • Columns in both but with different expression SQL: EXPRESSION_ALTERED

Pass 3: Join structure diffing

It identifies all joins in both trees by table alias. For each table present in both, it checks whether the join type changed (INNER JOIN to LEFT JOIN, for instance). Any join type change that introduces nulls in previously required columns is a structural mutation.

The result is a ModelASTDiff Pydantic model with frozen fields. This is the input to the lineage engine.


The Lineage DAG Engine

The lineage engine reads target/manifest.json, the compiled artifact that dbt writes after dbt compile. The manifest contains:

  • nodes: every model with its SQL, file path, and tags
  • exposures: downstream consumers (dashboards, ML models, reverse ETL)
  • child_map: a mapping from each node to its direct children

Parallax loads this into a NetworkX DiGraph:

class LineageGraph:
    def __init__(self, manifest: DbtManifest) -> None:
        self.graph = nx.DiGraph()
        self._build_graph()

    def _build_graph(self) -> None:
        for uid, data in self.manifest.nodes.items():
            self.graph.add_node(uid, name=data["name"], ...)

        for parent_id, children in self.manifest.child_map.items():
            for child_id in children:
                self.graph.add_edge(parent_id, child_id)

Once the graph is built, finding every downstream node is a single call:

descendants = nx.descendants(self.graph, modified_model_id)

NetworkX traverses the entire reachable subgraph using BFS. Even on large projects, this is a cheap in-memory operation.

Column-Level Lineage

If the AST diff found dropped or modified columns, the lineage engine traces where those columns are consumed downstream. It parses the SQL of every downstream model, finds references to the affected column names, and marks those models as having broken schema contracts.

The tracing is multi-hop. If stg_orders drops order_date, and int_customer_orders selects order_date from stg_orders and aliases it as placed_date, and fct_orders selects placed_date from int_customer_orders… the engine follows that derivation chain and reports broken contracts at every hop where the column is consumed but no longer available.

flowchart LR
    A["stg_orders\nDROPS order_date"]
    B["int_customer_orders\nSELECTS order_date\nfrom stg_orders\nBROKEN CONTRACT"]
    C["fct_orders\nSELECTS order_date\nas order_placed_date\nBROKEN CONTRACT"]
    D["rpt_monthly_finance_board\nGROUPS BY order_placed_date\nBROKEN CONTRACT"]
    E["Board Financials Summary\nExposure / DASHBOARD\nCFO IMPACT"]

    A -->|"order_date DROPPED"| B
    B -->|"order_date missing"| C
    C -->|"order_placed_date missing"| D
    D --> E

The Risk Scoring Engine

After the AST diff and lineage traversal, the risk engine applies eight rules in priority order to assign a severity tier:

PriorityRuleSeverity
1A downstream model references a column that was dropped or renamedCRITICAL
2A column was dropped and there are any downstream consumers at allCRITICAL
3A predicate was tightened, loosened, or mutated, and the blast radius reaches a BI exposure or a tier-1 tagged modelCRITICAL
4A join type changed (INNER to LEFT, etc.) and the blast radius reaches an exposure or tier-1 nodeCRITICAL
5A join type changed without reaching exposuresHIGH
6A predicate changed and more than 5 downstream models are affected, or a mart model is in the blast radiusHIGH
7A column was dropped with no known downstream consumers (yet)HIGH
8A calculation expression changed, or an isolated filter change with limited blast radiusMEDIUM

If none of these rules fire, the result is LOW. This covers whitespace changes, comment edits, and formatting-only reformats.

The fail_on configuration gate blocks the PR if the computed severity is at or above the threshold. The default is CRITICAL. You can raise it to HIGH for more aggressive gating or set it to NEVER to run Parallax in observation-only mode.


Data Model

Every piece of information that flows through Parallax lives in one of these Pydantic models. They are all frozen (immutable) after creation, which means the same input always produces the same output.

The diagram below is the full UML class model. Read it from the bottom up: a BlastRadiusReport is what you get at the end. Everything above it is what feeds into it.

classDiagram
    direction TB

    class BlastRadiusReport {
        +List~str~ modified_models
        +List~ModelASTDiff~ ast_diffs
        +List~DownstreamNode~ downstream_models
        +List~ExposureNode~ impacted_exposures
        +int max_dag_depth
        +RiskSeverity risk_severity
        +str plain_english_summary
        +List~str~ remediation_advice
        +float execution_duration_ms
        +List~ColumnImpact~ column_lineage_paths
        +has_breaking_changes() bool
    }

    class ModelASTDiff {
        +str model_name
        +str file_path
        +List~PredicateDiff~ predicates
        +List~ColumnDiff~ columns
        +StructuralDiff structural
        +has_semantic_changes() bool
        +dropped_columns() List~str~
    }

    class PredicateDiff {
        +PredicateClauseType clause
        +PredicateDiffType diff_type
        +str old_expression
        +str new_expression
        +str explanation
    }

    class ColumnDiff {
        +str column_name
        +ColumnDiffType diff_type
        +str old_expression
        +str new_expression
        +str explanation
    }

    class StructuralDiff {
        +List~JoinDiff~ join_diffs
        +bool group_by_altered
        +bool distinct_altered
        +str explanation
    }

    class JoinDiff {
        +str table_name
        +JoinDiffType diff_type
        +str old_join_type
        +str new_join_type
        +str explanation
    }

    class DownstreamNode {
        +str unique_id
        +str name
        +ModelLayer layer
        +List~str~ tags
        +List~str~ broken_columns
        +List~ColumnImpact~ column_impacts
        +int distance_from_source
    }

    class ExposureNode {
        +str name
        +str label
        +ExposureType exposure_type
        +str owner_name
        +str url
    }

    class ColumnImpact {
        +str model_name
        +str column_name
        +str upstream_model
        +str upstream_column
        +bool is_broken
        +List~str~ lineage_path
        +str expression_summary
    }

    class RiskSeverity {
        <<enumeration>>
        LOW
        MEDIUM
        HIGH
        CRITICAL
        NEVER
    }

    class PredicateDiffType {
        <<enumeration>>
        TIGHTENED
        LOOSENED
        DROPPED
        ADDED
        MUTATED_OPERATOR
    }

    class ColumnDiffType {
        <<enumeration>>
        DROPPED
        RENAMED
        EXPRESSION_ALTERED
        ADDED
    }

    class JoinDiffType {
        <<enumeration>>
        TYPE_CHANGED
        CONDITION_CHANGED
        JOIN_ADDED
        JOIN_REMOVED
    }

    class ModelLayer {
        <<enumeration>>
        STAGING
        INTERMEDIATE
        MARTS
        REPORTING
        OTHER
    }

    class ExposureType {
        <<enumeration>>
        DASHBOARD
        NOTEBOOK
        ML
        APPLICATION
        REVERSE_ETL
    }

    BlastRadiusReport "1" *-- "many" ModelASTDiff : ast_diffs
    BlastRadiusReport "1" *-- "many" DownstreamNode : downstream_models
    BlastRadiusReport "1" *-- "many" ExposureNode : impacted_exposures
    BlastRadiusReport "1" *-- "many" ColumnImpact : column_lineage_paths
    BlastRadiusReport --> RiskSeverity : risk_severity

    ModelASTDiff "1" *-- "many" PredicateDiff : predicates
    ModelASTDiff "1" *-- "many" ColumnDiff : columns
    ModelASTDiff "1" *-- "1" StructuralDiff : structural

    StructuralDiff "1" *-- "many" JoinDiff : join_diffs

    DownstreamNode "1" *-- "many" ColumnImpact : column_impacts
    DownstreamNode --> ModelLayer : layer

    ExposureNode --> ExposureType : exposure_type

    PredicateDiff --> PredicateDiffType : diff_type
    ColumnDiff --> ColumnDiffType : diff_type
    JoinDiff --> JoinDiffType : diff_type

Reading the diagram:

  • BlastRadiusReport is the final output. It holds everything: which models changed, which downstream models are affected, which exposures are at risk, what the risk level is, and the plain-English summary.
  • ModelASTDiff is the result of parsing one changed SQL file. It contains three lists: predicates (filter changes), columns (projection changes), and one structural object (join and group-by changes).
  • DownstreamNode is one model in the blast radius. It knows which layer it sits in, how many hops away it is from the change, and which of its columns are now broken because of the upstream edit.
  • ColumnImpact traces one column through the derivation chain. It records the model name, the column name, where the column came from upstream, and whether that derivation path is now broken.
  • The enumerations (RiskSeverity, PredicateDiffType, etc.) are the finite vocabularies that each classifier uses. Nothing in Parallax produces a freeform string where a structured enum value would do.

A Real Demo: The Filter Tightening That Breaks the Board Dashboard

This is the scenario built into parallax demo. It simulates a real class of breakage.

The change

An engineer is cleaning up stg_orders. They want the model to only reflect successfully delivered orders. They make two edits:

-- BEFORE: models/staging/stg_orders.sql
SELECT
    order_id,
    customer_id,
    status,
    order_total * 0.95 as net_booked_amount,
    order_date
FROM {{ ref('raw_orders') }}
WHERE status NOT IN ('returned', 'cancelled');

-- AFTER: models/staging/stg_orders.sql
SELECT
    order_id,
    customer_id,
    status,
    order_total * 0.95 as net_booked_amount
FROM {{ ref('raw_orders') }}
WHERE status = 'delivered';

Two things changed:

  1. The filter went from excluding bad statuses to requiring a specific good status. Orders with status pending, in_transit, processing, and disputed now disappear entirely.
  2. The order_date column was removed from the projection.

What the AST engine sees

Parallax parses both versions and produces this diff:

Predicate change on stg_orders (WHERE clause):
  TIGHTENED: status NOT IN ('returned', 'cancelled') -> status = 'delivered'
  Explanation: Filter tightened from negative exclusion to strict match, omitting unhandled categories.

Column change on stg_orders (SELECT projection):
  DROPPED: order_date
  Explanation: Column removed from projection.

What the lineage engine finds

The DAG traversal from stg_orders reaches:

stg_orders (modified)
  |
  +-- int_customer_orders     [hop 1]  BROKEN: selects order_date
  |     |
  |     +-- fct_orders        [hop 2]  BROKEN: derives order_placed_date from order_date
  |           |
  |           +-- rpt_executive_kpis      [hop 3]
  |           +-- rpt_monthly_finance_board [hop 3]  BROKEN: groups by order_placed_date
  |           +-- rpt_sales_commission_sync [hop 3]
  |           +-- rpt_regional_performance  [hop 3]
  |           +-- rpt_cohort_retention      [hop 3]
  |           +-- rpt_daily_pipeline        [hop 3]
  |
  +-- int_net_payments        [hop 1]
  +-- int_order_items         [hop 1]
  +-- int_subscription_periods [hop 1]
  |
  +-- dim_customers           [hop 2]
  +-- dim_products            [hop 2]
  +-- dim_sales_reps          [hop 2]
  +-- dim_subscriptions       [hop 2]
  +-- fct_churn_daily         [hop 2]
  +-- fct_customer_transactions [hop 2]
  +-- fct_mrr_monthly         [hop 2]

Impacted BI Exposures:
  Board Financials Summary      DASHBOARD   VP Finance
  Executive ARR Dashboard       DASHBOARD   Chief Financial Officer
  Sales Commission Sync         REVERSE_ETL Sales Ops

18 downstream models across 4 hops. 3 executive exposures. The Board Financials dashboard shows SUM(gross_amount) grouped by order_placed_date, which derives from order_date in stg_orders. That derivation chain is now broken.

What the risk engine decides

Three rules fire simultaneously:

  1. rpt_monthly_finance_board has broken columns. Severity: CRITICAL.
  2. The predicate was tightened and the blast radius includes executive exposures. Severity: CRITICAL.
  3. Dropped column exists with downstream consumers. Severity: CRITICAL.

The output:

CRITICAL RISK: PR tightened filter `status NOT IN ('returned', 'cancelled')` ->
`status = 'delivered'` on `stg_orders`. This cascades across 18 downstream models
and impacts 3 Executive Exposures (Board Financials Summary, Executive ARR Dashboard,
Sales Commission Sync).

Recommended Actions:
1. Fix schema reference: Model `int_customer_orders` relies on column(s) `order_date`
   which were modified or deleted.
2. Verify business metrics: Confirm that filter/calculation changes do not unintentionally
   alter executive metrics on: Board Financials Summary, Executive ARR Dashboard,
   Sales Commission Sync.
3. Audit dropped records: Confirm whether omitting non-matching statuses (e.g.
   pending/in-transit/disputed) was intended by business stakeholders.

CI gate: BLOCK

The GitHub Action exits with code 1 and the PR cannot merge.

Parallax in action: the full CLI walkthrough

The walkthrough below shows the complete parallax demo run: the Rich terminal output, the blast radius table, the tree of affected models, and the remediation advice. This is what an engineer sees when the tool catches the filter tightening.

Parallax CLI walkthrough showing blast radius analysis and CRITICAL risk output

What the GitHub PR comment looks like

Every time Parallax runs in CI it posts a pinned comment on the PR. If the engineer pushes a fix and CI re-runs, the existing comment is updated in place. No new comment is created.

GitHub PR comment with blast radius table, exposure list, and remediation checklist

The standalone HTML report

Running parallax report generates an offline HTML file with an interactive SVG DAG, node inspection panel, and copyable remediation checklist. No server required.

Standalone Parallax HTML report with interactive DAG and risk summary


Performance Benchmarks at Scale

Because Parallax evaluates AST diffs and graph traversals entirely in memory without network roundtrips or warehouse compute, execution overhead in CI is negligible:

Project SizeModelsDAG DepthTotal Execution
Small startup~50 models4 hops~0.28s
Mid-market team~350 models8 hops~0.85s
Enterprise monorepo~1,800+ models14 hops~2.40s

Benchmarks measured on local development environments with cached manifests. Performance scales with project size and number of modified files.


Sequence Diagram: A PR Lifecycle with Parallax

sequenceDiagram
    participant Eng as Engineer
    participant GH as GitHub
    participant CI as GitHub Actions Runner
    participant PX as Parallax
    participant DBT as dbt compile
    participant PR as PR Comment

    Eng->>GH: git push (edits stg_orders.sql)
    GH->>CI: trigger pull_request workflow
    CI->>DBT: dbt compile
    DBT-->>CI: target/manifest.json
    CI->>PX: parallax check --manifest target/manifest.json
    PX->>PX: git diff base..HEAD -> changed SQL files
    PX->>PX: SQLGlot parse (base SQL, head SQL)
    PX->>PX: ASTDiffEngine.diff_model()
    Note over PX: PredicateDiff: TIGHTENED<br/>ColumnDiff: DROPPED order_date
    PX->>PX: DbtManifest.load(manifest.json)
    PX->>PX: LineageGraph._build_graph()
    PX->>PX: nx.descendants(stg_orders)
    Note over PX: 18 models, 3 exposures found
    PX->>PX: ColumnLineageEngine.trace()
    Note over PX: order_date broken at 3 hops
    PX->>PX: RiskEngine.evaluate() -> CRITICAL
    PX->>PR: POST /repos/.../issues/.../comments
    Note over PR: Pinned comment with<br/>blast radius table,<br/>remediation checklist
    PX-->>CI: exit code 1
    CI-->>GH: status check FAILED
    GH-->>Eng: PR blocked - merge button disabled
    Eng->>GH: reads PR comment, fixes the SQL
    Eng->>GH: git push (updated fix)
    GH->>CI: trigger pull_request workflow (new commit)
    CI->>PX: parallax check (re-runs)
    PX->>PR: PATCH existing comment (update in place, no spam)
    PX-->>CI: exit code 0 (LOW risk)
    CI-->>GH: status check PASSED
    GH-->>Eng: PR ready to merge

The pinned PR comment updates in place on every subsequent commit. No new comment is posted. Parallax searches for its own HTML marker in existing PR comments before deciding whether to create or update.


How to Run and Test

Prerequisites: Python 3.10 or later and git.

Clone the repository from GitHub and install locally:

git clone https://github.com/Ramprasad273/parallax.git
cd parallax
pip install -e .

Or install directly from the GitHub repository:

pip install git+https://github.com/Ramprasad273/parallax.git

Run the built-in demo (no dbt project needed):

parallax demo

This runs the filter tightening scenario described above against a synthetic manifest. You get the full terminal output with blast radius tables and remediation advice.

Run against your actual dbt project:

# After running dbt compile in your project root
dbt compile
parallax check --manifest target/manifest.json --base origin/main

Test with the example Airflow and dbt sandbox repository:

To test Parallax against a real data engineering codebase with multiple transformation layers rather than the synthetic demo, clone the example sandbox repository:

# 1. Clone the example data engineering repository
git clone https://github.com/Ramprasad273/Data-Engineering.git
cd Data-Engineering/de_projects/airflow_sandbox/dbt_project

# 2. Compile the dbt models into manifest.json
dbt compile

# 3. Run the blast radius check against your base branch
parallax check --manifest target/manifest.json --base origin/main

The Airflow and dbt Sandbox project includes realistic multi-tier dbt pipelines (staging, intermediate, and marts) with hospital workflows and abnormal lab event transformations. You can simulate editing a filter or modifying a column in models/intermediate/int_abnormal_lab_events.sql to observe how Parallax traces downstream dependencies, checks contracts, and reports the blast radius before merging.

Generate a standalone HTML report:

parallax report --out blast_radius_report.html

This writes a self-contained HTML file with an interactive SVG DAG, node inspection drawer, and copyable remediation checklist. No server required. Open it in any browser.

Output formats:

# Rich terminal output (default)
parallax check

# Markdown for PR comments or documentation
parallax check --format markdown --output pr_comment.md

# JSON for custom pipelines or dashboards
parallax check --format json

Zero Failure Mode: Graceful Degradation in CI

A critical requirement for any CI gate is that it must never fail a build due to parser limitations. If AST parsing encounters an unsupported syntax construct or vendor-specific extension that SQLGlot cannot parse, Parallax degrades gracefully: it preserves full topological DAG lineage traversal from manifest.json, issues a non-blocking diagnostic warning in the report, and does not crash the CI runner. A parse failure on one SQL file never blocks analysis of the remaining changed files.


GitHub Action Setup

Add this to your .github/workflows/dbt_ci.yml after dbt compile:

name: "dbt CI"
on:
  pull_request:
    paths:
      - "models/**"

jobs:
  blast_radius_check:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Compile dbt Manifest
        run: dbt compile

      - name: Run Parallax Blast Radius CI
        uses: Ramprasad273/parallax@v0.1.0
        with:
          manifest: target/manifest.json
          fail_on: CRITICAL
          github_token: ${{ secrets.GITHUB_TOKEN }}

The action needs pull-requests: write to post and update the pinned comment. It needs contents: read to run git diff. No other permissions required. No cloud credentials. No warehouse access.

Available inputs:

InputDefaultDescription
manifesttarget/manifest.jsonPath to the compiled dbt manifest
baseorigin/mainBase branch for the diff
headHEADPR head reference
dialectsnowflakeSQL dialect for parsing
fail_onCRITICALMinimum severity to block the PR
github_token${{ github.token }}Token for PR comments

Configuration

Parallax works with zero configuration by default. For custom governance rules, place .parallax.yml in your repository root:

version: 1
dialect: snowflake          # snowflake, bigquery, postgres, duckdb, databricks
manifest_path: target/manifest.json
base_ref: origin/main
fail_on: CRITICAL           # LOW, MEDIUM, HIGH, CRITICAL, NEVER

# Models tagged with these values are treated as tier-1
# A predicate or join change that reaches them escalates to CRITICAL
tier_tags:
  - tier_1
  - finance
  - executive
  - board

# These model paths are excluded from blast radius analysis
ignore_patterns:
  - "models/sandbox/**"
  - "models/dev_*"

If .parallax.yml does not exist, all defaults apply. The tier_tags setting is what triggers CRITICAL escalation for filter changes that stop short of BI exposures but still hit tagged business-critical models.


Dialect Support

Parallax uses SQLGlot for parsing. The current supported dialects are:

DialectStatus
SnowflakeFull
BigQueryFull
PostgreSQLFull
DuckDBFull
Databricks / Spark SQLFull
ANSI SQLFull
MySQLPartial

The dialect is applied during AST parsing and during column name normalization. Snowflake and BigQuery are case-insensitive. Parallax normalizes all column names to lowercase before comparing across models so cross-dialect case differences do not produce false positives.


What Parallax Does Not Do

Being clear about scope is important.

It does not validate data. Parallax does not connect to your warehouse. It cannot tell you whether the rows that pass status = 'delivered' are actually correct. It tells you that fewer rows will pass than before, and that this affects your CFO dashboard. Whether that is intentional is a business decision.

It does not replace dbt tests. Schema tests, uniqueness tests, and referential integrity checks belong in dbt. Parallax is the layer before that, catching structural and semantic changes before tests can even run.

It does not detect runtime query plan changes. A filter change that does not appear semantically in the SQL (for example, a value that comes from a variable or Jinja macro that resolves differently at runtime) is invisible to static analysis.

It does not track file renames. In v0.1.0, Parallax invokes git diff --no-renames. A renamed file is treated as a delete of the old path and an add of the new path. This is the conservative default: it flags any downstream models still referencing the old path. Heuristic rename tracking is on the roadmap.


What the Demo Report Looks Like in the Terminal

The screenshot below is the actual parallax demo output from a local run against the stg_orders scenario. It shows the Rich-formatted blast radius table, the exposure list, and the remediation checklist exactly as an engineer would see them on their machine.

Parallax CLI terminal output showing blast radius table, impacted exposures, and remediation advice


Why No LLMs

Every summary sentence, every remediation line, and every risk decision in Parallax is produced by deterministic rule-based code. No LLM is called at runtime. This is a deliberate design choice.

LLM-generated summaries are non-deterministic. Running the same input twice can produce different output. For a CI gate that decides whether a PR can merge, non-determinism is unacceptable. The risk decision must be the same on every commit, on every runner, at any time of day.

LLM calls also add latency, external API dependencies, cost, and failure modes. A CI tool that calls an external API can fail because of rate limits, network timeouts, or API changes. Parallax has no external runtime dependencies after installation.

Parallax also collects zero usage data. There are no analytics pings, no posthog callbacks, no anonymous telemetry. The only outbound HTTP call is to api.github.com using your own GITHUB_TOKEN when posting PR comments, a connection you explicitly authorize when you install the action.


Testing and Code Quality

The test suite covers:

  • AST diffing: predicate classification, column drop detection, join type changes, numeric threshold comparison, IN list contraction and expansion
  • Lineage traversal: single-hop, multi-hop, exposure discovery, broken column detection
  • Risk scoring: each of the 8 rules, boundary conditions, tier tag escalation
  • Column-level lineage: multi-hop derivation tracing, case-insensitive matching
  • CLI: output format switching, exit code behavior, manifest loading
# Run the full test suite with coverage
pytest -v --cov=parallax --cov-report=term-missing

# Lint and type check
ruff check parallax tests
mypy parallax

The CI gate requires 80% test coverage before any commit can merge. Type annotations are required on all public functions. The Mypy config uses disallow_untyped_defs = true.


Contributing

Parallax is Apache 2.0 licensed and accepts contributions.

To add a new SQL dialect:

  1. Verify SQLGlot supports parsing it: sqlglot.parse(sql, read="your_dialect").
  2. Add case-sensitivity handling in ColumnLineageEngine._normalize_col_name().
  3. Add a test file under tests/ that covers the dialect-specific AST patterns.

To add a new risk rule:

  1. Add the rule in RiskEngine._calculate_severity() at the appropriate priority position.
  2. Add the corresponding summary generation in RiskEngine._generate_summary().
  3. Add remediation logic in RiskEngine._generate_remediation().
  4. Add test cases covering the new rule and its boundary conditions.

To add a new output formatter:

  1. Create a class in parallax/cli/formatters/ implementing a render(report: BlastRadiusReport) -> str method.
  2. Register it in parallax/cli/main.py under the --format option.

See the Developer Contribution Guide for the full local setup and testing workflow.


GitHub and Resources

git clone https://github.com/Ramprasad273/parallax.git
cd parallax && pip install -e .
parallax demo

Apache 2.0. Issues, PRs, and dialect contributions welcome.


dbt™ is a registered trademark of dbt Labs, Inc. Parallax is an independent open-source project and is not affiliated with, sponsored by, or endorsed by dbt Labs, Inc.

RP
Ram Prasad

Lead Data & AI Engineer wrestling Spark clusters by day and building LLM internals from scratch by night.

// DISCUSSION & FEEDBACK

Join the Conversation

Have questions about this architecture, benchmarks, or pipeline code? Leave a reply below or join our GitHub Discussions.

Storage: Stored securely in your GitHub Discussions repository (via Giscus)