Redshift Guide
AWSOfficialPublished by AWS
- Skill
- Tool integration
- Cloud and DevOps
- Apache-2.0
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The Redshift Guide is a technical reference designed to prevent common errors when working with Amazon Redshift. Because Redshift shares a wire protocol with PostgreSQL, AI models often incorrectly apply PostgreSQL syntax and behaviors to Redshift. This guide provides the necessary corrections, covering DDL, system metadata, query performance, storage management, and authentication. It distinguishes between provisioned clusters and Serverless workgroups, ensuring users apply the correct system views and API calls for their specific environment.
What it does
- Corrects common misconceptions where PostgreSQL syntax is incorrectly applied to Redshift.
- Provides guidance on Redshift-specific DDL, including distribution and sort keys.
- Outlines the correct system views (SYS_ vs STL_) for monitoring and metadata discovery.
- Details secure authentication practices for JDBC/ODBC and the Data API.
- Offers troubleshooting steps for storage issues, query performance, and COPY/UNLOAD operations.
- Enforces the use of Redshift-specific commands over PostgreSQL-native alternatives.
When to use it
- When you are unsure if a PostgreSQL command or function is supported in Redshift.
- When troubleshooting performance issues, disk space, or query errors in Redshift.
- When setting up new tables, loading data via COPY, or exporting data via UNLOAD.
- When configuring IAM roles, permissions, or connectivity for Redshift clusters or workgroups.
- When you need to identify the correct system views for monitoring a specific Redshift deployment.
Advantages
- Prevents errors caused by assuming PostgreSQL compatibility.
- Provides clear distinctions between provisioned and Serverless environments.
- Focuses on high-impact performance tuning like distribution and sort keys.
- Promotes secure, least-privilege access patterns for data operations.
- Reduces time spent debugging by providing a structured routing table for common issues.
Usage examples
- “How do I create a table in Redshift with a sort key?”
- The agent will provide a CREATE TABLE statement using DISTSTYLE and SORTKEY, explaining that Redshift uses these instead of standard PostgreSQL indexes.
- “Why is my query failing with a 'relation does not exist' error?”
- The agent will guide you to use SHOW commands or SYS_ views to inspect the schema and verify table existence, rather than querying information_schema.
- “How do I debug a failed COPY command?”
- The agent will instruct you to query SYS_LOAD_ERROR_DETAIL to identify the specific row or column causing the load failure.
- “Is my Redshift cluster running out of disk space?”
- The agent will help you distinguish between actual data growth and temporary query spill, and guide you to use the appropriate storage utilization references.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/aws/redshift-guide/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip redshift-guide.zip -d .agents/skills
Documentation by AWS
Amazon Redshift Guide
Redshift is NOT PostgreSQL (read first)
Redshift speaks PostgreSQL's wire protocol and shares much of its surface syntax, so
LLMs assume PostgreSQL behavior carries over — it frequently does not. Divergences span
system tables (pg_catalog is incomplete), DDL (no indexes, no sequences), functions
(string_agg, SUBSTR on tables, leader-node-only functions), types (a text column
becomes VARCHAR(256)), and comparison semantics (trailing blanks, unenforced constraints). Assume
divergence and verify against the reference below — do not answer from PostgreSQL habit.
Common PostgreSQL→Redshift divergences are in references/redshift-sql-syntax.md.
Works best with the AWS MCP server — it runs the AWS CLI and Redshift Data API calls below in a sandboxed, audit-logged environment. All guidance here is plain AWS CLI and SQL and works without it.
STEP 0: Serverless or Provisioned?
Establish this before answering — APIs, system tables, and capabilities differ. Take it
from the question when it says which one; ask when it does not. SELECT version()
does not identify it.
Serverless — identified by a workgroup (and namespace). Data API calls take
--workgroup-name; the user says "workgroup"/"Serverless".Provisioned — identified by a cluster. Data API calls take
--cluster-identifier; the user says "cluster".
| Target | System Views | Credentials API |
|---|---|---|
| Provisioned | SYS_, all SVV_ + STL_, STV_, SVL_, SVCS_ (single-AZ only — disabled on Multi-AZ) | redshift:GetClusterCredentials |
| Serverless | SYS_ + a subset of SVV_ ONLY (no STL/STV/SVL/SVCS) | redshift-serverless:GetCredentials |
Critical Facts
SHOW commands are the primary metadata interface — SHOW DATABASES, SHOW SCHEMAS, SHOW TABLES, SHOW COLUMNS, SHOW TABLE, SHOW VIEW. Do NOT default to pg_catalog or information_schema. → Load
references/redshift-sql-metadata.mdfor metadata/discovery questions and any "relation does not exist" report — it has the diagnostic flow.SYS_views are the preferred system views — they work everywhere.STL_,STV_,SVL_, andSVCS_are provisioned single-AZ only, and someSVV_views are unsupported on Serverless. → Loadreferences/redshift-sql-metadata.mdfor any system-view or monitoring question.sys_load_error_detailfor COPY debugging (notstl_load_errors, which is provisioned single-AZ only).DATEADD/DATEDIFF — unit-first argument order:
DATEADD(day, -30, GETDATE()),DATEDIFF(day, start, end).APPROXIMATE COUNT(DISTINCT col) — Redshift-specific, ~2% error, much faster than exact COUNT(DISTINCT) on large datasets.
MERGE ... REMOVE DUPLICATES — simplified dedup when source and target have identical schemas.
COPY should use IAM_ROLE (the namespace role, not the caller role) + supports MANIFEST for explicit file lists + MAXERROR for error tolerance.
SUBSTR()is leader-node-only — works on literals but errors on table columns (SUBSTR() function is not supported (Hint: use SUBSTRING instead)). UseSUBSTRING()on columns.UNIQUE / PRIMARY KEY / FOREIGN KEY are informational only — NOT enforced (duplicate rows are accepted with no error). Optimizer hints; enforce integrity in the application or via MERGE.
NOT NULLIS enforced.SHOW VIEW <schema.name>returns the definition of a regular view, materialized view, or late-binding view. MV freshness:SVV_MV_INFO(is_stale).No
FILTER (WHERE ...)on aggregates — PostgreSQL-only, Redshift rejects it. WriteCOUNT(CASE WHEN cond THEN 1 END)/SUM(CASE WHEN cond THEN x END)instead. This applies to every query you emit, including ones you derive from a reference example.TOP NandLIMIT Nboth work (TOP N PERCENTdoes not). Atextcolumn becomesVARCHAR(256)— useVARCHAR(max)or explicit length.Iceberg tables use
CREATE TABLE ... USING ICEBERG(notSTORED AS ICEBERG, notTABLE_FORMAT=ICEBERG).Datashares support read and write operations — consumers can write once the producer grants write privileges. Treat "permission denied" on a datashare write as a missing grant, not an unsupported operation. → Load
references/redshift-sql-metadata.mdfor requirements and limits.Storage: attribute before you remediate, and never reflex-VACUUM. Disk-full is usually NOT data growth — rule out transient query spill (temp blocks) and per-node/slice skew first, because the cluster average hides a single full node. On RA3/Serverless a high local reading is cache, not out-of-space, and free managed storage does not prevent a node-local Disk Full.
SVV_TABLE_INFO.unsortedalone is misleading — gate sorting onvacuum_sort_benefit. "Storage is growing / what is driving it" is a different question from "storage is high": it needs a trend over months (CloudWatch, not a point-in-time view), per-table attribution is always partial, and the per-table sum is never the cluster total. → Loadreferences/redshift-storage-utilization.mdto attribute space, thenreferences/redshift-storage-remediation.mdto act.
Safety Guardrails
BLOCK: DROP DATABASE, DELETE without WHERE, publicly-accessible=true, GRANT ALL ON ALL
WARN then confirm: RESIZE, RESTORE, VACUUM on large tables, ALTER PASSWORD, WLM config change, TRUNCATE / DROP TABLE — TRUNCATE forces an implicit COMMIT and cannot be rolled back (it also commits other pending work in the same transaction), so name the target table and say the rows are unrecoverable before proceeding; treat it as routine only on temp/staging tables the user has already called disposable
Confirm: CREATE, GRANT specific, COPY, UNLOAD
Security Considerations
Apply these defaults when generating anything that connects, loads, or exports. Details are in the reference files noted.
In transit: the Data API is HTTPS-only. For JDBC/ODBC set the
require_sslparameter and connect withsslmode=verify-fullso the server certificate is checked.At rest: keep cluster/namespace encryption enabled, and add
ENCRYPTED KMS_KEY_ID '<arn>'toUNLOAD— it writes query results to S3, outside Redshift's own encryption. →references/redshift-sql-ddl-copy.mdCredentials: prefer
SecretArn(Secrets Manager) or IAM Identity Center;DbUseris acceptable because it issues temporary credentials. Never place database passwords in code, environment variables, or SQL text. →references/redshift-sql-recipes-load-api.mdLeast privilege: scope the namespace
IAM_ROLEto the specific bucket and prefix (s3:GetObjectonarn:aws:s3:::<bucket>/<prefix>/*), nots3:*or a managed full-access policy, and condition its trust policy on bothaws:SourceArn(the cluster/namespace ARN) andaws:SourceAccount—SourceArnalone still allows another resource in the account to assume it. Grant per-object privileges rather thanGRANT ALL ON ALL.Audit: CloudTrail records
redshift-data:*API calls but not the SQL executed; enable Redshift audit logging (useractivitylog,connectionlog,userlog) for that. Both capture query text and user activity, so encrypt every destination in use: the CloudWatch Logs group (aws logs associate-kms-key), the CloudTrail trail (SSE-KMS), and the audit-log S3 bucket (SSE-S3 — audit logging to S3 supports only S3-managed keys, not KMS). Serverless only supports sending audit logs to CloudWatch.Network: keep
PubliclyAccessible=falseand connect over a VPC endpoint. Do not open port 5439 to0.0.0.0/0or::/0— scope inbound rules to specific CIDRs or to a referencing security group.Sensitive data: Data API results persist for 24h and
sys_load_error_detailcan echo fragments of rejected rows, so treat statement IDs and load-error output as sensitive.Further reading: Security in Amazon Redshift for the full guidance behind these defaults.
Routing Table
MANDATORY: When a question matches a row below, you MUST load and read the referenced file BEFORE answering.
Ask whether the target is provisioned or Serverless before giving troubleshooting steps — unless the question already says which one, in which case use that and do not re-confirm.
| User Intent | Route To |
|---|---|
| "CREATE TABLE", "DISTKEY/SORTKEY", "ENCODE", "IDENTITY", "COPY", "UNLOAD", "IAM_ROLE", "Iceberg table" | references/redshift-sql-ddl-copy.md |
| "LISTAGG", "DATEADD/DATEDIFF", "NVL/DECODE", "type mapping", "text type", "VARBYTE", "recursive CTE" | references/redshift-sql-functions-types.md |
| "QUALIFY", "PIVOT/UNPIVOT", "MERGE", "TOP N", "SUBSTR error", "UNIQUE/PK not enforced", "trailing blanks", "leader-node function", "JSON", "SUPER", "PartiQL", "nested/semi-structured data" | references/redshift-sql-extensions-semantics.md |
| "system view", "SVV_/SYS_", "SHOW commands", "STL vs SYS", "list tables", "distkey/sortkey lookup", "datashare discovery", "2-part vs 3-part", "permission denied", "GRANT", "privileges", "relation/table does not exist" | references/redshift-sql-metadata.md |
| "how do I write SQL", "PostgreSQL vs Redshift", "which SQL reference", general dialect question | references/redshift-sql-syntax.md (index of the 6 SQL references + PostgreSQL-vs-Redshift failure table) |
| "COPY failed", "load error", "Data API poll", "async query", "Data API throttle" | references/redshift-sql-recipes-load-api.md |
| "materialized view", "MV refresh", "AUTO REFRESH", "stale view" | references/redshift-sql-materialized-views.md |
"disk full" / error 1016 / 34507, "PercentageDiskSpaceUsed", "storage grew", "what's consuming space", "ghost rows", "tombstone", "spill to disk", "managed storage vs local SSD", "datashare cache", "one node full / storage skew", "Serverless storage inflation", "what is driving the storage increase" / "storage keeps climbing" / "grew N% per month" / "which tables are growing" / "is vacuum missing" — a growth-over-time question, answered by the "Is it growing, and what is driving it?" section: split it into four (is it really that full, is it vacuum debt, how much would VACUUM return, what is driving the trend), trend from CloudWatch over months, per-table attribution from SYS_VACUUM_HISTORY, and state plainly what cannot be attributed from customer-side views, any snapshot / backup / recovery-point billing or cost question — including "AWS Backup", "cross-region snapshot copy", "backup billed at full size", "why is snapshot storage this expensive", "incremental so it should be cheaper" (answer from the per-type × per-platform matrix in S16, never from AWS Backup's own incremental-copy documentation, which describes AWS Backup's behavior and not Redshift's manual-snapshot metering), "cluster unavailable / stuck after hitting 100% storage" (confirm the fill first — PercentageDiskSpaceUsed in CloudWatch, since the SQL views are unreachable while the cluster is down, and rule out the non-storage causes before assuming disk) — attribute the space first | references/redshift-storage-utilization.md |
| "VACUUM won't finish / times out", "which VACUUM mode — DELETE ONLY / SORT ONLY / FULL / RECLUSTER", "vacuum_sort_benefit", "unsorted", "reclaim space", "deep copy", "auto-vacuum CPU", "distribution key skew", "recovering a cluster stuck or unavailable at ~100% storage — headroom plus escalation", "resize failed and stuck in canceling-resize" — take action | references/redshift-storage-remediation.md |
| "slow Redshift query", "query was fast now slow", "why is my query slow", "query performance", "time decomposition", "queue vs execution time", "planning/compile time", "result cache hit / false fast", "EXPLAIN plan", "DS_BCAST_INNER / broadcast / nested loop / merge join", "stale statistics / ANALYZE / bad row estimate", "function on join key / plan flip", "distribution skew / skew_rows / one slice", "did the query change / query hash", "row multiplication / join fan-out / DISTINCT slow", "sys_query_history / sys_query_detail" | references/redshift-sql-query-performance.md |
| "ready-to-run performance query", "which SQL to run", "diagnostic queries", "execution step / skew / spill", "plan diff between runs", "svv_table_info table health", "auto-analyze / auto-vacuum history", "lock wait / transaction history" | references/redshift-sql-query-performance-queries.md |
| "connection timed out/refused", "SSO/federated login fails", "Response signature invalid", "assumeRoleWithSAML", "RedshiftDbUser principal tag", "sts:TagSession AccessDenied", "IAM Identity Center region", "connection limit exceeded", "ODBC/JDBC driver", "ODBC 1.x EOL/CVE", "SSL error/certificate verify failed", "permission denied to query database created from datashare", "SYSLOG ACCESS/system-table row visibility", "GetClusterCredentials AccessDenied" | references/redshift-auth-authorization.md |
| General Redshift question not matching above | Answer directly from general knowledge |
| Aurora, RDS, DynamoDB, Athena (non-Redshift) | REFUSE. State this skill is for Amazon Redshift only. Do not provide guidance for other database services. |
Data API Quick Reference
→ Load references/redshift-sql-recipes-load-api.md before answering ANY Data API, COPY-error, or async-query question. It carries the bounded poll loop, the HasResultSet and ResourceNotFoundException handling, the per-target parameters, and the auth options.
Data API calls are async by default — use long polling (--wait-time-seconds, 1–30)
rather than blind sleeps, and keep a bounded loop for work that can exceed 30s.
Serverless takes --workgroup-name, provisioned takes --cluster-identifier.
Files
- ATTRIBUTION.mdAdded by AutoGineer908 B
- LICENSELicence file added by AutoGineer9.9 KiB
- NOTICELicence file added by AutoGineer67 B
- SKILL.md14.7 KiB
- references/redshift-auth-authorization.md24.1 KiB
- references/redshift-sql-ddl-copy.md4.9 KiB
- references/redshift-sql-extensions-semantics.md4.0 KiB
- references/redshift-sql-functions-types.md4.2 KiB
- references/redshift-sql-materialized-views.md2.9 KiB
- references/redshift-sql-metadata.md10.3 KiB
- references/redshift-sql-query-performance-queries.md22.8 KiB
- references/redshift-sql-query-performance.md34.6 KiB
- references/redshift-sql-recipes-load-api.md7.7 KiB
- references/redshift-sql-syntax.md4.0 KiB
- references/redshift-storage-remediation.md37.8 KiB
- references/redshift-storage-utilization.md34.0 KiB
references/redshift-sql-query-performance-queries.md
Redshift Query Performance — Ready-to-run Diagnostic Queries
Ready-to-run diagnostic queries for redshift-sql-query-performance.md (start with the
phase-triage query there). All use portable SYS_*/SVV_* views (work on both Serverless
and provisioned). All *_time / duration columns are in microseconds; the queries return
them unconverted and unrounded. Access: without elevated access these queries fail or under-return. As a non-superuser,
SYS_* views generally show only your own rows (e.g. SYS_QUERY_HISTORY, SYS_QUERY_DETAIL),
and some views are superuser-only and return permission denied (e.g. SVV_TABLE_INFO,
SYS_ANALYZE_HISTORY, SYS_AUTO_TABLE_OPTIMIZATION). Grant the
sys:monitor default role:
it lifts the per-user filter and opens the superuser-only views.
GRANT SELECT ON <view_name, identifier, no quotes> TO <user_name, identifier, no quotes> only
fixes permission denied for one view; it doesn't lift the own-rows filter. Note the two views answer different questions:
SVV_TABLE_INFO.stats_off reports staleness now, while SYS_QUERY_DETAIL (the per-step
alert column, e.g. a missing-statistics alert — no superuser needed) tells you whether a
specific run was planned on stale stats when it ran.
Identify the workload before searching. A query_id, session_id or transaction_id
identifies it directly; filter on it. A time window, user or database only narrows the search:
combine them with something from the statement itself (a text snippet, a query label, or the
procedure name).
Stored procedure — find the slow statement(s) (both deployments)
A CALL runs many statements, and each inner statement gets its own query_id in
SYS_QUERY_HISTORY. The CALL itself is logged as its own row whose start_time/end_time
span the whole procedure. Anchor on that CALL row, then rank the statements that ran in
the same session_id inside the CALL's time window — often several statements are slow,
so work down the ranked list (not just the top one) and drill into each with the execution
query below.
Do not filter by transaction_id alone — it is wrong in both directions:
If the
CALLsits inside an explicitBEGIN…COMMIT, the proc's statements share one xid with the surrounding non-proc statements, so an xid filter over-captures the siblings.A
NONATOMICproc that issues an internalCOMMIT/TRUNCATE(or DDL) starts a new xid per statement, so the proc's statements span several xids and an xid filter under-captures everything after the first commit.
Scoping by session_id + the CALL's [start_time, end_time) window handles both: a session
runs statements serially, so nothing else executes in that window, and it captures the proc's
statements no matter how many transactions they span.
-- Step 1 — find the proc CALL and grab its session_id + start/end time window.
-- The CALL is logged as its own row with the invocation in its query text ('call <proc>...'),
-- so match on the query text.
-- Use the CALL row only for its session_id and time window. Don't decompose its time buckets;
-- decompose the inner statements (Step 2) instead.
SELECT query_id AS call_query_id, session_id, transaction_id, query_type, status,
elapsed_time, start_time, end_time,
query_text AS call_text
FROM sys_query_history
WHERE query_text ILIKE 'call %<proc_name, identifier, no quotes>%'
AND query_text NOT ILIKE '%sys_query_history%' -- don't match this lookup itself
ORDER BY elapsed_time DESC -- slowest CALLs first, wherever they fall in history
LIMIT 10;-- Step 2 — rank every statement the proc ran, slowest-first, with each one's share of the
-- proc's total time (pct_of_proc). Scope by session_id + the CALL's [start_time, end_time)
-- window: this excludes sibling statements in the same explicit transaction AND still captures
-- inner statements when an internal COMMIT split them across xids. Drill into each slow
-- query_id with the "Execution drill-down" query below.
SELECT query_id, transaction_id, query_type, status,
elapsed_time,
100.0 * elapsed_time / NULLIF(SUM(elapsed_time) OVER (), 0) AS pct_of_proc,
start_time, query_text
FROM sys_query_history
WHERE session_id = <session_id_from_step_1, integer, no quotes>
AND start_time >= <call_start_time_from_step_1, timestamp, single quotes> -- inclusive lower bound
AND start_time < <call_end_time_from_step_1, timestamp, single quotes> -- exclusive: drops post-CALL siblings
AND query_id <> <call_query_id_from_step_1, integer, no quotes> -- exclude the CALL row itself
ORDER BY elapsed_time DESC
LIMIT 50;Execution drill-down — skew, spill, alerts (both deployments)
-- Once execution_time dominates, find the expensive segment, then the step inside it.
-- 1) Rank segments. duration, reads and spill are recorded per SEGMENT (step rows carry 0 for reads
-- and spill, and a copy of the segment's duration).
SELECT child_query_sequence, stream_id, segment_id, duration, time_skewness,
blocks_read, spilled_block_local_disk, spilled_block_remote_disk
FROM sys_query_detail
WHERE user_id > 1 AND query_id = <query_id, integer, no quotes>
AND metrics_level = 'segment'
ORDER BY duration DESC;
-- 2) Steps of the slowest segment(s): rows, row skew and alerts identify the expensive step.
-- Take child_query_sequence and segment_id from the top rows of query 1 — segment ids restart
-- in each child query, so filter on both (run once per child query if the top rows span several).
SELECT child_query_sequence, segment_id, step_id, step_name, table_name,
input_rows, output_rows,
data_skewness, -- high => the step's rows were spread very unevenly
alert -- missing stats / nested loop / large broadcast
FROM sys_query_detail
WHERE user_id > 1 AND query_id = <query_id, integer, no quotes>
AND metrics_level = 'step'
AND child_query_sequence = <child_query_sequence, integer, no quotes>
AND segment_id IN (<segment_ids, integer list, comma-separated>)
ORDER BY segment_id, output_rows DESC;Plan shape + auto-optimization recommendations (both deployments)
-- System-generated EXPLAIN plan: look for DS_BCAST_INNER / DS_DIST_INNER / DS_DIST_BOTH and
-- nested-loop nodes. DS_DIST_BOTH (both sides redistributed) is the most data movement.
-- Data redistribution: https://docs.aws.amazon.com/redshift/latest/dg/c_data_redistribution.html
-- Then compare the planner's estimated rows here against actual input_rows/output_rows in the
-- execution drill-down above — a large gap points at stale/missing stats driving a bad plan.
SELECT query_id, child_query_sequence, plan_node_id, plan_parent_id, plan_node, plan_info
FROM sys_query_explain WHERE query_id = <query_id, integer, no quotes>
ORDER BY child_query_sequence, plan_node_id;
-- Pending Automatic Table Optimization dist/sort recommendations — defer to these before hand-rolling DDL.
SELECT type, "database", table_id, group_id, ddl, auto_eligible
FROM svv_alter_table_recommendations ORDER BY table_id;Plan diff — what changed between the fast and slow run (both deployments)
-- Step 1: pair one fast + one slow run of the SAME query. Two ways to group runs of the same
-- statement — use whichever is handy:
-- (a) the built-in generic_query_hash (same text ignoring literal VALUES) or user_query_hash
-- (same text including literals) in sys_query_history — simplest, no length limit; or
-- (b) the NORMALIZED SQL text fingerprint below — purely lexical, masks literal VALUES only, not
-- identifiers (a digit glued to a name like sales_2026 or col::int8 is kept). (Redshift
-- REGEXP_REPLACE uses POSIX word boundaries [[:<:]]/[[:>:]].)
WITH candidates AS ( -- runs of this statement only
SELECT query_id, elapsed_time, start_time
FROM sys_query_history
WHERE user_id > 1
AND start_time >= DATEADD(day, -7, GETDATE()) -- SYS_* history retention
AND query_text ILIKE '%<tag_or_snippet, string, no quotes>%'
AND result_cache_hit = false -- a cache hit isn't a real baseline
)
SELECT c.query_id, c.elapsed_time, c.start_time,
MD5(LOWER(regexp_replace(regexp_replace(regexp_replace(
LISTAGG(TRIM(t.text)) WITHIN GROUP (ORDER BY t.sequence),
'''[^'']*''', '?'), -- string literals -> ?
'[[:<:]][0-9]+([.][0-9]+)?[[:>:]]', '?'), -- numeric literal VALUES -> ? (identifiers kept)
'[[:space:]]+', ' '))) AS query_text_hash -- collapse whitespace
FROM candidates c JOIN sys_query_text t USING (query_id)
WHERE t.sequence <= 15 -- LISTAGG caps at 65,535 chars; ~15 sys_query_text chunks fit under that.
-- Longer statements truncate here (small collision risk) — for very long
-- SQL, group on generic_query_hash (option a) instead, which has no limit.
GROUP BY c.query_id, c.elapsed_time, c.start_time
ORDER BY query_text_hash, c.elapsed_time; -- within one query_text_hash: first row = fast, last = slow-- Step 2: normalized PLAN HASH per child_query_sequence — decide WHETHER the plan shape changed
-- before diffing nodes. Strips volatile cost/rows/width and normalizes literals, and excludes the
-- "-----" missing-stats banner rows. Equal hash (fast vs slow) => shape unchanged (go to the
-- data/stats check below); different for a child => the plan flipped there, run Step 3.
SELECT query_id, child_query_sequence,
MD5(LISTAGG(
regexp_replace(
regexp_replace(
regexp_replace(plan_node || '|' || COALESCE(plan_info,''),
'volt_[a-z0-9_]+', 'volt_tt'), -- normalize temp-table names
'cost=[0-9.]+\.\.[0-9.]+|rows=[0-9]+|width=[0-9]+', ''), -- drop volatile estimates
'''[^'']*''|[[:<:]][0-9]+([.][0-9]+)?[[:>:]]', '?') -- normalize literal VALUES (names kept)
, '~') WITHIN GROUP (ORDER BY plan_node_id, plan_node)) AS plan_hash
FROM sys_query_explain
WHERE query_id IN (<fast_query_id, integer, no quotes>, <slow_query_id, integer, no quotes>)
AND plan_node NOT LIKE '-----%'
GROUP BY query_id, child_query_sequence
ORDER BY child_query_sequence, query_id;-- Step 3 (only for a child whose hash differs): pull both plans and compare them as TREES, per
-- child_query_sequence. plan_node_id numbering follows the plan's shape: once the shape changes, ids
-- shift, so don't match nodes by id:
-- walk parent -> child (plan_parent_id) and find the subtree whose plan_node (DS_* redistribution /
-- join-type flip) or plan_info (predicate placement) differs. Watch for a "Tables missing statistics" node.
SELECT query_id, child_query_sequence, plan_node_id, plan_parent_id, plan_node, plan_info
FROM sys_query_explain
WHERE query_id IN (<fast_query_id, integer, no quotes>, <slow_query_id, integer, no quotes>)
ORDER BY query_id, child_query_sequence, plan_node_id; -- one full plan per run, not interleavedStep 4: once a child's plan flipped, drill into the slow run's expensive steps to see which
operator consumed the time. SYS_QUERY_DETAIL carries plan_node_id, so you can join it to
SYS_QUERY_EXPLAIN on query_id + child_query_sequence + plan_node_id to label each expensive step with its plan
operator — and because each scan of a repeated table gets its own plan_node_id, this
disambiguates the "same table appears in several scan steps" case. Treat it as best-effort, not a
complete 1:1 map: some plan nodes have no runtime row (coordinator / no-op nodes) and some steps have
a blank table_name (intermediate results). Drill from the slow steps and attach the operator label:
-- Estimate vs actual: plan_node carries the planner's rows= estimate; input_rows/output_rows are the
-- step's actual rows. One plan node can cover several steps (e.g. a scan and a distribute), so compare
-- the estimate with the node's last step. A runtime filter can lower a probe-side scan's actual rows
-- below its estimate without the statistics being wrong.
SELECT d.child_query_sequence, d.segment_id, d.step_id, d.step_name,
d.duration, d.input_rows, d.output_rows, -- actual rows, to compare with rows= in plan_node
d.data_skewness, d.alert, -- duration is the step's segment's; spill/reads are on segment rows only
d.plan_node_id, e.plan_node -- operator label + estimate (rows=); NULL if the step has no plan node
FROM sys_query_detail d
LEFT JOIN sys_query_explain e
ON e.query_id = d.query_id
AND e.child_query_sequence = d.child_query_sequence
AND e.plan_node_id = d.plan_node_id
AND e.query_id = <query_id, integer, no quotes> -- filters sys_query_explain early
WHERE d.query_id = <query_id, integer, no quotes> AND d.step_id >= 0
ORDER BY d.duration DESC, d.output_rows DESC;Did the data / stats move under the query? (both deployments)
-- Same SQL, different plan (or slower) with no query change? Check what moved underneath,
-- using SVV_TABLE_INFO (current whole-table rows + staleness).
-- 1) did an ANALYZE (manual OR auto) run BETWEEN the two runs? refreshes stats -> plan can flip.
-- is_automatic distinguishes auto-analyze from a manual ANALYZE.
SELECT database_name, schema_name, table_name, table_id, is_automatic, status,
start_time, end_time, rows, modified_rows, analyze_threshold_percent, last_analyze_time
FROM sys_analyze_history
WHERE start_time BETWEEN <earlier_run_start_time, timestamp, single quotes>
AND <later_run_start_time, timestamp, single quotes>
-- only tables the slow query read (SYS_QUERY_DETAIL also lists internal ids with no name — skip those)
AND table_id IN (SELECT table_id FROM sys_query_detail
WHERE query_id = <slow_query_id, integer, no quotes> AND TRIM(table_name) <> '')
ORDER BY start_time DESC;
-- 2) stale-stats check: high stats_off = a lot of DML has landed since the last ANALYZE, so the
-- planner is on stale/absent stats and can pick a bad plan. Use SVV_TABLE_INFO.stats_off
-- (staleness) and tbl_rows (current rows) — not pg_class.reltuples, which is only a rough estimate.
-- Same-named tables in other schemas also match; see the note on the table-health query.
SELECT ti."database", ti."schema", ti."table", ti.tbl_rows AS current_rows, ti.stats_off,
ti.unsorted, ti.vacuum_sort_benefit, ti.skew_rows
FROM svv_table_info ti
WHERE ti."table" IN (<table_names, string list, single-quoted and comma-separated>)
ORDER BY ti.stats_off DESC;
-- 3) physical-design change (auto OR manual) that alters the plan:
-- a) AUTOMATIC dist/sort changes Redshift applied between the runs (Automatic Table
-- Optimization). alter_from shows the table's style/keys before the change.
SELECT o.event_time, q.table_name, o.table_id, TRIM(o.alter_table_type) AS alter_table_type,
TRIM(o.status) AS status, TRIM(o.alter_from) AS alter_from
FROM sys_auto_table_optimization o
JOIN (SELECT DISTINCT table_id, table_name FROM sys_query_detail -- only tables the slow query read
WHERE query_id = <slow_query_id, integer, no quotes> AND TRIM(table_name) <> '') q
ON q.table_id = o.table_id
WHERE o.status = 'Complete'
AND o.event_time BETWEEN <earlier_run_start_time, timestamp, single quotes>
AND <later_run_start_time, timestamp, single quotes>
ORDER BY o.event_time;
-- b) MANUAL design change between the runs, on any table the slow query reads — an ALTER, or a
-- rebuild (DROP + CREATE, or CREATE TABLE AS + rename):
SELECT query_id, start_time, query_type, query_text AS ddl_text
FROM sys_query_history
WHERE query_type IN ('DDL', 'CTAS')
AND start_time BETWEEN <earlier_run_start_time, timestamp, single quotes>
AND <later_run_start_time, timestamp, single quotes>
-- One ILIKE per table the slow query reads: every table_name in SYS_QUERY_DETAIL for the slow
-- query_id (includes tables behind views). table_name is 'db.schema.table' — match on the table
-- part only (e.g. 'sales', not 'dev.public.sales'). Also add one ILIKE per VIEW named in the slow
-- query's text: SYS_QUERY_DETAIL lists only base tables, so a CREATE OR REPLACE VIEW between the
-- runs would otherwise be missed. ILIKE matches substrings ('sales' also hits 'sales_stg'), so
-- confirm each hit in ddl_text. Add "OR query_text ILIKE '%<table_name, identifier, no quotes>%'" for each.
AND (query_text ILIKE '%<table_name, identifier, no quotes>%')
ORDER BY start_time DESC LIMIT 20;Table health — skew / unsorted / stats / ghost rows (both deployments)
-- ghost_rows = deleted rows not yet reclaimed (fix: VACUUM DELETE ONLY). vacuum_sort_benefit > 0 is
-- required before recommending VACUUM SORT; it says nothing about the delete phase.
-- SYS_QUERY_DETAIL.table_name is database.schema.table: use its table part for this filter, and its
-- schema part to pick the right row when tables with the same name exist in other schemas ("schema"
-- column). SVV_TABLE_INFO covers only the database you're connected to.
SELECT "database","schema","table", diststyle, sortkey1, tbl_rows, estimated_visible_rows,
(tbl_rows - estimated_visible_rows) AS ghost_rows,
unsorted, vacuum_sort_benefit, stats_off, skew_rows, skew_sortkey1
FROM svv_table_info
WHERE tbl_rows > 0
AND "table" IN (<table_names, string list, single-quoted and comma-separated>) -- every table the slow query reads (table part of SYS_QUERY_DETAIL.table_name)
ORDER BY stats_off DESC, vacuum_sort_benefit DESC, skew_rows DESC;Did auto-analyze / auto-vacuum already act? (both deployments)
-- Confirm stats/maintenance freshness BEFORE recommending manual ANALYZE / VACUUM.
-- A 'Skipped' status is NORMAL for background auto-analyze (it judged stats still fresh enough) — not a failure.
SELECT schema_name, table_name, is_automatic, status, start_time, last_analyze_time
FROM sys_analyze_history
WHERE start_time >= DATEADD(day, -7, GETDATE()) -- SYS_* history retention
-- only tables the slow query read (SYS_QUERY_DETAIL also lists internal ids with no name — skip those)
AND table_id IN (SELECT table_id FROM sys_query_detail
WHERE query_id = <slow_query_id, integer, no quotes> AND TRIM(table_name) <> '')
ORDER BY start_time DESC;
SELECT schema_name, table_name, vacuum_type, is_automatic, status, start_time,
reclaimable_rows, reclaimed_rows
FROM sys_vacuum_history
WHERE start_time >= DATEADD(day, -7, GETDATE()) -- SYS_* history retention
-- only tables the slow query read (SYS_QUERY_DETAIL also lists internal ids with no name — skip those)
AND table_id IN (SELECT table_id FROM sys_query_detail
WHERE query_id = <slow_query_id, integer, no quotes> AND TRIM(table_name) <> '')
ORDER BY start_time DESC;Result-cache loss for a recurring query (both deployments)
-- Group identical statements by user_query_hash (NOT query_hash). Find the last cache hit before the
-- slow run, then list it and every run after it: the first result_cache_hit = false row after the
-- hit is where the cache was invalidated (data changed or the cached result aged out). A miss with
-- high compile_time/execution_time after prior hits = cache invalidation, not a plan regression.
WITH last_hit AS (
SELECT MAX(start_time) AS hit_time
FROM sys_query_history
WHERE user_id > 1 AND user_query_hash = <user_query_hash, string, single quotes>
AND result_cache_hit = true
AND start_time <= <slow_run_start_time, timestamp, single quotes>
)
SELECT h.query_id, h.result_cache_hit, h.elapsed_time, h.execution_time, h.compile_time, h.start_time
FROM sys_query_history h, last_hit l
WHERE h.user_id > 1 AND h.user_query_hash = <user_query_hash, string, single quotes>
AND h.start_time >= COALESCE(l.hit_time, DATEADD(day, -7, GETDATE())) -- no hit in retention: show all
ORDER BY h.start_time;Queue contention — what a query queued behind (both deployments)
-- High queue_time? List queries whose run overlapped the slow query's run — candidates for what it
-- queued behind. Overlap alone doesn't prove contention: a candidate's own start_time includes its
-- waiting, so check its queue_time (a row that was itself queued wasn't using concurrency or
-- memory), and a row that started after the slow query began executing can't have caused its
-- queuing. Replace <query_id>.
-- WLM scope: the service_class_id filter below is OFF by default (automatic WLM shares concurrency
-- and memory cluster-wide). Turn it ON only when the slow query's service_class_id is 6–13 (a
-- manual WLM queue) or 14 (short query acceleration): those compete only for their own slots.
WITH target AS (
SELECT service_class_id, start_time, end_time
FROM sys_query_history WHERE query_id = <query_id, integer, no quotes>
)
SELECT h.query_id, h.user_id, h.service_class_id,
h.queue_time,
h.execution_time,
h.start_time, h.end_time, h.query_text
FROM sys_query_history h, target t
WHERE h.query_id <> <query_id, integer, no quotes>
-- AND h.service_class_id = t.service_class_id -- ON only for 6–14, see WLM scope above
AND h.start_time <= t.end_time
AND (h.end_time IS NULL OR h.end_time >= t.start_time) -- overlaps the slow query's run (include still-running queries)
ORDER BY h.execution_time DESC
LIMIT 20;Lock wait — transaction history (both deployments)
-- The per-query wait on a lock is the lock_wait_time bucket in SYS_QUERY_HISTORY (that's what tells
-- you a query waited). This lists committed transactions that overlapped the slow query's run:
-- CANDIDATE lock holders, not a confirmed holder — the view records no table or lock. Narrow them
-- by looking up each transaction_id's statements in SYS_QUERY_HISTORY: one that wrote to a table
-- the slow query reads is the likely holder. Only transactions that wrote data appear here; a
-- holder that only read or took a lock without writing does not.
WITH target AS (
SELECT start_time, end_time
FROM sys_query_history WHERE query_id = <query_id, integer, no quotes>
)
SELECT th.user_id, th.transaction_id, th.isolation_level, th.status,
th.transaction_start_time, th.commit_start_time, th.commit_end_time
FROM sys_transaction_history th, target t
WHERE th.user_id > 1
AND th.transaction_start_time <= t.end_time
AND (th.commit_end_time IS NULL OR th.commit_end_time >= t.start_time)
ORDER BY th.transaction_start_time;-- Live locks right now, only on tables someone is waiting for: granted = false is a waiter; a
-- granted row on the same relation is the holder.
SELECT txn_owner, txn_db, xid, pid, txn_start, lock_mode, lockable_object_type, relation, granted
FROM svv_transactions
WHERE relation IN (SELECT relation FROM svv_transactions WHERE granted = false)
ORDER BY relation, granted DESC, txn_start;Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs an MCP server, Needs Node.js
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- AWS
- Publisher
- AWS (Official)
- Source
- aws/agent-toolkit-for-aws · plugins/aws-data-analytics/skills/redshift-guide · commit 0a218f8
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- NOTICE (added by AutoGineer from NOTICE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
- Also distributed in
- aws/agent-toolkit-for-aws
AutoGineer is not affiliated with or endorsed by AWS.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download redshift-guide.zip