Redshift Query Decomposer
Compiles a monolithic Amazon Redshift query into a staged, DISTKEY/SORTKEY-tuned pipeline of temp tables — with the reasoning shown.
Somewhere right now, a 2-billion-row table is being scanned in full to answer a question about last Tuesday. This library exists for that query: it parses Redshift SQL, explodes the views, pushes the safe predicates down, prunes the columns, picks distribution and sort keys for each stage, and hands back a script of narrow temp tables plus a rewritten final query — and explains every decision it made.
pip install redshift-query-decomposer
from redshift_decomposer import decompose, assess_decomposability
No database connection required. The library never touches your cluster — it has no idea what your password is, and would like to keep it that way.
Safety: Generated scripts are candidates. Always validate row counts, nulls, and EXPLAIN plans before production use. The tool says this too, on every single plan, because it means it.
Before → After
Before — the query your BI tool wrote. It looks innocent, which is how they always look:
SELECT o.order_id, c.region, o.amount
FROM analytics.reporting.v_orders o
JOIN analytics.public.dim_customer c
ON o.cust_id = c.cust_id
WHERE o.order_date >= DATE '2024-01-01'
AND c.segment = 'enterprise'
What's actually behind it: v_orders is a view hiding
fact_orders — 2,000,000,000 rows × 14 columns (~420 GB) — and the join
key, the filters, and the three columns anyone asked for are buried inside it.
Redshift will happily scan all of it.
After — actual, unedited library output, shown piece by piece with what each piece used to be.
Stage 1 — the dimension, cut down to the enterprise slice
This was:
analytics.public.dim_customer, 5M rows joined in full, itssegmentfilter applied only at the end. Now: only enterprise rows, co-located on the join key before the join happens.
DROP TABLE IF EXISTS tmp_rsd_01_dim_customer;
CREATE TEMP TABLE tmp_rsd_01_dim_customer
DISTKEY("cust_id")
SORTKEY("segment")
AS
SELECT
"cust_id", "region", "segment"
FROM "analytics"."public"."dim_customer" AS src
WHERE (src.segment = 'enterprise');
ANALYZE tmp_rsd_01_dim_customer;
Stage 2 — the deeply embedded fact table, extracted to need-to-know
This was: the 2-billion-row, 14-column
fact_ordershiding inside the view —channel,promo_code,warehouse_id,carrier,ship_date,return_flag,tax,discount,etl_batch_idall along for a ride nobody asked them to take. Now: 5 of 14 columns — the three the query returns, the join key, andstatus(kept because the view's own WHERE clause needs it — the planner reads view bodies, not just your query). The date filter was pushed through the view alias into the stage,DISTKEY(cust_id)is preserved from the source so the join stays local, andSORTKEY(order_date)keeps the pushed range filter fast.
DROP TABLE IF EXISTS tmp_rsd_02_fact_orders;
CREATE TEMP TABLE tmp_rsd_02_fact_orders
DISTKEY("cust_id")
SORTKEY("order_date")
AS
SELECT
"order_id", "cust_id", "order_date", "amount", "status"
FROM "analytics"."public"."fact_orders" AS src
WHERE (src.order_date >= CAST('2024-01-01' AS DATE));
ANALYZE tmp_rsd_02_fact_orders;
Final query — the view, unmasked
This was:
FROM analytics.reporting.v_orders o— a black box. Now: the view is inlined as a visible subquery with itsstatus <> 'CANCELLED'guard preserved, reading from the slim staged temp instead of the 420 GB original. Semantics identical; I/O is not.
SELECT
o.order_id,
c.region,
o.amount
FROM (
SELECT
order_id,
cust_id,
order_date,
amount,
status
FROM tmp_rsd_02_fact_orders AS fact_orders
WHERE
status <> 'CANCELLED'
) AS o
JOIN tmp_rsd_01_dim_customer AS c
ON o.cust_id = c.cust_id
WHERE
o.order_date >= CAST('2024-01-01' AS DATE) AND c.segment = 'enterprise';
The receipts
Every stage explains itself — this column is generated, not hand-written:
| Stage | Was | Became | Rationale (verbatim) |
|---|---|---|---|
tmp_rsd_02_fact_orders |
2B rows × 14 cols behind a view | 5 need-to-know cols, date-filtered | large source (~2,000,000,000 rows, 420,000 MB); pushed 1 predicate(s); DISTKEY preserves source key cust_id; SORTKEY preserves source leading key order_date |
tmp_rsd_01_dim_customer |
5M-row dim, filtered after the join | enterprise slice, filtered before it | large source (~5,000,000 rows, 800 MB); pushed 1 predicate(s); DISTKEY from join column cust_id; SORTKEY supports pushed filter on segment |
The full example, runnable (click to expand)
from redshift_decomposer import Catalog, TableStats, ViewDef, decompose
catalog = Catalog(
tables={
"analytics.public.fact_orders": TableStats(
columns={c: "VARCHAR" for c in (
"order_id", "cust_id", "order_date", "amount", "status",
"channel", "promo_code", "warehouse_id", "carrier",
"ship_date", "return_flag", "tax", "discount", "etl_batch_id",
)},
diststyle="KEY",
distkey="cust_id",
sortkeys=("order_date",),
rows=2_000_000_000,
size_mb=420_000,
),
"analytics.public.dim_customer": TableStats(
columns={"cust_id": "BIGINT", "region": "VARCHAR", "segment": "VARCHAR"},
diststyle="ALL",
rows=5_000_000,
size_mb=800,
),
},
views={
"analytics.reporting.v_orders": ViewDef(
sql="""
SELECT order_id, cust_id, order_date, amount, status
FROM analytics.public.fact_orders
WHERE status <> 'CANCELLED'
"""
),
},
)
plan = decompose(
"""
SELECT o.order_id, c.region, o.amount
FROM analytics.reporting.v_orders o
JOIN analytics.public.dim_customer c
ON o.cust_id = c.cust_id
WHERE o.order_date >= DATE '2024-01-01'
AND c.segment = 'enterprise'
""",
catalog,
)
print(plan.script)
for stage in plan.stages:
print(stage.name, stage.distkey, stage.sortkeys, stage.rationale)
Triage first: will decompose() even work on this?
For when someone hands you 400 lines of SQL and says "it's slow" — the scorer
answers in milliseconds, before you commit your afternoon. Parse-only, no
catalog, no connection. The 0.0–1.0 score is the estimated likelihood that
decompose() will produce a usable, semantically-safe staged plan — not a
runtime-savings estimate, and not a person-hours estimate. Every deduction is
a named signal tied to real planner behavior:
from redshift_decomposer import assess_decomposability
report = assess_decomposability(sql)
print(report.summary())
print(report.likelihood, report.blocking)
[######----] 0.65 MODERATE likelihood - plan expected, review findings likely
likelihood reductions:
-0.25 Correlated subquery: 1 nested scope(s) reference outer aliases; ...
-0.10 Window functions: 1 window expression(s); ...
| Band | Meaning |
|---|---|
| ≥ 0.70 HIGH | Strong chance of a usable plan — safe to decompose live |
| 0.40–0.70 MODERATE | Plan expected; read the review findings before running |
| 0.05–0.40 LOW | Show the reasons; decompose only with caution |
| ≤ 0.05 UNLIKELY | Not a candidate (unparseable, DML, recursive CTE, …) |
It knows about SUPER/JSON manipulation, recursive CTEs, true correlated
subqueries (uncorrelated IN (SELECT …) is not penalized), multi-alias
self-joins, the CTE filter-pushdown gap, Spectrum/external schemas, set
operations, missing filters, deep nesting, and SELECT * — which remains
perfectly legal; proving it was a good idea is another matter.
Also runs standalone with nothing but sqlglot installed:
python -m redshift_decomposer.triage # paste a query interactively
How it works
monolithic SQL ──▶ parse ──▶ explode views ──▶ analyze lineage & predicates
(sqlglot, redshift dialect) │
▼
final query ◀── rewrite ◀── plan stages (CTAS + DISTKEY/SORTKEY
(over temps) + column pruning + rationale + safety label)
| You provide | Redshift Query Decomposer produces |
|---|---|
| Query text | Multi-statement Redshift script |
| Column schemas for referenced tables | Qualified / pruned stage SELECTs |
| Optional physical stats (rows, size, dist/sort keys) | DISTKEY / SORTKEY on temps |
| View SQL definitions | Inlined (exploded) physical plan |
Feeding it a catalog
Cluster-wide table repository cache — recommended; build once, reuse forever
SVV_TABLE_INFO is per-database and can be monstrously slow — run the
build overnight so it queries every database once and you never have to again:
- List local databases (
svv_redshift_databaseswheredatabase_type = 'local'— excludes datashares) - Open a new connection per database (Redshift cannot switch DB in-session)
- Capture full
SVV_TABLE_INFO+pg_table_def(compound sort key positions, distkey, types) - Store everything in one SQLite file
Lookup policy: cache hit → use it; miss → live SVV/pg_table_def (optional write-through).
import redshift_connector
from redshift_decomposer import build_table_repository, decompose, TableRepository
def connect(database: str):
return redshift_connector.connect(
host="...", database=database, user="...", password="..."
)
# Slow path — schedule overnight / after major DDL
report = build_table_repository(
connect,
path="C:/cache/redshift_table_repo.sqlite",
bootstrap_database="analytics",
)
print(report.databases_ok, report.table_count)
# Fast path — metrics from cache; live only on misses / views
plan = decompose(
sql,
repository="C:/cache/redshift_table_repo.sqlite",
connect=connect,
database="analytics",
)
repo = TableRepository("C:/cache/redshift_table_repo.sqlite")
print(repo.get_table("analytics", "public", "fact_orders").sortkeys) # full compound key
Sort keys: SVV_TABLE_INFO.sortkey1 is only the leading column. The
repository merges pg_table_def.sortkey positions so compound
SORTKEY(a, b, c) is preserved.
Live fetch / offline frames — the quick alternatives
plan = decompose(sql, connection=conn) # current database only
Offline frames (e.g. DataBasix DuckDB):
from redshift_decomposer import catalog_from_databasix_frames, decompose
catalog = catalog_from_databasix_frames(table_info_df, view_definitions_df)
plan = decompose(sql, catalog)
Non-superuser / alternate-schema table info — exact field names
Live connection / repository build always query system catalogs
(SVV_TABLE_INFO, pg_table_def, information_schema.columns, pg_views).
There is no built-in switch yet for “use my schema’s mirror of SVV.”
Workaround today: prepare readable tables (any schema/database you control),
query them yourself, and feed rows via catalog_from_rows,
catalog_from_databasix_frames, or a hand-built Catalog. As long as the
result column names match what the package expects, the planner does not
care whether the data came from SVV_TABLE_INFO or analytics_meta.my_table_info.
Table-metrics mirror (like SVV_TABLE_INFO)
Use these exact field names in the result set (case-insensitive; the loader normalizes keys to lower case). Aliases listed on the same line are accepted equivalents.
Identity (required)
| Field name(s) | Meaning |
|---|---|
schema (or schema_name, schemaname) |
Schema of the physical table |
table or table_name (or tablename) |
Table name |
database (or source_db, redshift_database_name) |
Optional but recommended when multi-DB; else pass default_database= |
Without schema + table/table_name, the row is ignored.
Physical design & size (strongly recommended — drives staging / keys)
| Field name(s) | Meaning | Used for |
|---|---|---|
tbl_rows (or rows) |
Approximate row count | Stage if ≥ minimum_rows |
size (or size_mb) |
Size in MB (Redshift SVV_TABLE_INFO.size is already MB) |
Stage if ≥ minimum_size_mb |
diststyle |
e.g. EVEN, ALL, KEY(cust_id) |
Style + parse dist key |
distkey |
Dist-key column name | Preferred over parsing diststyle when set |
sortkey1 (or sortkey) |
Leading sort-key column (SVV name is sortkey1) |
Stage SORTKEY fallback |
sortkeys |
Optional list/tuple of full compound sort keys | Better than sortkey1 alone |
Health / stats fields you asked about
| Field name | Meaning | Notes |
|---|---|---|
stats_off |
Stats off percentage (SVV_TABLE_INFO.stats_off) |
Stored in the table repository cache. Not consumed by the planner today for stage decisions, but include it so mirrors stay SVV-compatible and future versions can use it. |
sortkey1 |
Leading sort key (SVV_TABLE_INFO.sortkey1) |
Used (see above). |
distkey |
Distribution key column | Used when present; else parsed from diststyle via KEY(...). |
unsorted |
Percent of the table that is unsorted (SVV_TABLE_INFO.unsorted) |
This is Redshift’s field — not “percent sorted.” Percent sorted ≈ 100 - unsorted. Stored in the repository as unsorted. Not used by the planner today; still include it for parity. There is no accepted alias percent_sorted / pct_sorted yet. |
Other fields the package knows / stores (include when available)
| Field name | Meaning |
|---|---|
table_id |
OID / table id |
encoded |
Compression encoding summary |
sortkey_num |
Number of sort-key columns |
pct_used |
Percent of disk used |
empty |
Empty-block percentage |
skew_sortkey1 |
Skew on leading sort key |
skew_rows |
Row skew across slices |
estimated_visible_rows |
Estimated visible rows |
create_time |
Table create time |
max_varchar |
Max varchar length (stored in repository extras) |
object_type / is_external |
Mark Spectrum / external tables |
Minimal example mirror (what non-superusers should prepare)
At least:
schema
table -- or table_name
tbl_rows -- or rows
size -- or size_mb (megabytes)
diststyle
distkey
sortkey1
stats_off
unsorted -- percent unsorted; percent sorted ≈ 100 - unsorted
Better (full SVV-shaped capture used by the repository build):
database
schema
table_name -- package also accepts column name "table"
table_id
encoded
diststyle
sortkey1
sortkey_num
size
pct_used
empty
unsorted
stats_off
tbl_rows
skew_sortkey1
skew_rows
estimated_visible_rows
create_time
distkey -- if your extract stores it as its own column
Example feed path:
from redshift_decomposer import catalog_from_rows, decompose
# You run: SELECT ... FROM my_meta.svv_table_info_mirror
# with the field names above in the result set.
catalog = catalog_from_rows(
table_rows, # list[dict] or frame rows
column_rows=column_rows, # optional — see below
view_rows=view_rows, # optional — for view explosion
default_database="analytics",
)
plan = decompose(sql, catalog)
Column-definition mirror (like pg_table_def / information_schema.columns)
Needed for column pruning, types, and full compound sort keys (SVV only
gives sortkey1). Exact names accepted:
| Field name(s) | Meaning |
|---|---|
schema (or table_schema, schema_name, schemaname) |
Schema |
table_name (or table) |
Table |
column_name (or column) |
Column |
data_type (or type) |
Type string |
database (or table_catalog) |
Optional |
ordinal_position |
Optional order |
distkey |
Boolean/flag — column is the dist key (pg_table_def) |
sortkey |
Integer position in compound sort key (pg_table_def; 0 = not in key) |
encoding |
Optional |
is_not_null / notnull |
Optional |
View-definition mirror (like pg_views)
| Field name(s) | Meaning |
|---|---|
schema (or schema_name, schemaname) |
Schema |
view_name (or viewname) |
View name |
source_definition (or definition, view_definition) |
View SQL body |
database (or source_db) |
Optional |
is_late_binding |
Optional |
What the planner actually needs today vs. nice-to-have
| Need | Fields |
|---|---|
| Required for useful staging | schema, table/table_name, tbl_rows/rows, size/size_mb |
| Required for good DISTKEY/SORTKEY on temps | diststyle and/or distkey, sortkey1 (or full sortkeys / pg_table_def sortkey positions) |
| Required for column pruning / qualify | Column mirror: column_name, data_type (+ schema/table) |
| Required for view explosion | View mirror: source_definition (+ schema/view name) |
| Stored for repository parity; not planner inputs yet | stats_off, unsorted, skew_rows, skew_sortkey1, pct_used, empty, encoded, … |
Important: Live auto-fetch still hardcodes FROM SVV_TABLE_INFO (and friends).
Alternate-schema mirrors work only through bring-your-own rows
(catalog_from_rows / frames / Catalog) until a metadata_schema /
relation override is added.
Design principles
- SQLGlot-first — parse, transform, generate with
dialect="redshift" - Catalog-required for advanced work — no silent network catalog fetches
- Conservative defaults — refuse or warn on unsafe boundaries (outer
joins, multi-alias shared stages, unresolved
*); a wrong rewrite is worse than no rewrite - Honest output — every stage explains itself; every plan says what to review
- Publishable core — no GUI, DuckDB, or connector dependency
Known limitations (0.2.x)
Stated plainly, because you should know them before trusting a plan — and because we would rather you hear it from us:
- Predicate pushdown reads top-level WHERE clauses only. A large table referenced solely inside a CTE body or a set-operation branch can be staged without its filter — an unfiltered copy that may cost more than it saves. Review stage SQL before running (every plan tells you to). Fix planned for 0.3.x.
- Column qualification degrades when catalog keys (
db.schema.table) and query references (schema.table) disagree on depth; plans fall back to catalog-order projection instead of proven pruning. - Decomposition targets read queries; DML/DDL and recursive CTEs are refused — the triage scorer will tell you so before you find out the hard way.
- This is not query decorrelation, federation, or a dbt replacement — it rewrites one query into explicit staged steps you can read and tune.
Status
Alpha (0.2.x) — the API may still change; the honesty is permanent. Focus
is a correct, testable decomposition pipeline that can grow more advanced
staging strategies without breaking the public API.
License
MIT © 2026 Ryan Capece
Metadata
Release files for redshift-query-decomposer 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| redshift_query_decomposer-0.2.2.tar.gz | 63.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| redshift_query_decomposer-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 117.7 kB
Release files / redshift_query_decomposer-0.2.2.tar.gz
| Download URL | redshift_query_decomposer-0.2.2.tar.gz |
|---|---|
| Size | 63.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
723723302d3c69e07a50689d9b5d6e1678329d596c5aa75cdbb5d44ba076e218
|
|
BLAKE2b-256 checksum How to use checksums |
bebf1b9c24e9964b08a1174972606436f4d29a358778eb68958b5b2beabcefa7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.13
|
Release files / redshift_query_decomposer-0.2.2-py3-none-any.whl
| Download URL | redshift_query_decomposer-0.2.2-py3-none-any.whl |
|---|---|
| Size | 54.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
67dd7b028d7bd58ce14599cc94c505fbe5148770793ea1b0a1426d66ff977602
|
|
BLAKE2b-256 checksum How to use checksums |
0fd9f4614af8cd5c34ea5889f96d4fb57b38c5d107f470f74f0647e41f5e8038
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.13
|