← Files AWS Data AnalyticsARCHIVED FILE

skills/redshift-guide/references/redshift-sql-recipes-load-api.md

7.59 KB · Sep 30, 2026 · 22:52 UTC

↓ Download file

# Redshift Recipes: COPY & Data API (Working Code)

Procedural patterns agents must SHOW as working code, not describe as rules.

## Recipe: COPY with error handling

```bash
#!/bin/bash
# Load CSV from S3. Tolerates bad rows, diagnoses failures via Data API.
WORKGROUP="<workgroup_name, string, no quotes>"
DB="<database, string, no quotes>"
TABLE="<schema.table, identifier, no quotes>"
ROLE_ARN="<iam_role_arn, string, no quotes>"
S3_PATH="<s3_uri, string, no quotes>"

# These values are interpolated straight into the SQL text. Set them yourself, or
# validate them (allowlist the identifier, verify the s3:// URI) before use — a table
# name or path taken from user input is a SQL-injection vector here. The Data API's
# --parameters option binds values, but not identifiers, so it does not cover $TABLE.
#
# --wait-time-seconds (1-30) is long polling: the call returns as soon as the
# statement finishes, so a short load needs no polling at all. A COPY can exceed
# 30s, so still loop — but each iteration waits up to 30s instead of sleeping
# blindly, which cuts calls against a TPS-limited quota.
STMT_ID=$(aws redshift-data execute-statement \
  --workgroup-name "$WORKGROUP" --database "$DB" --wait-time-seconds 30 \
  --sql "COPY $TABLE FROM '$S3_PATH' IAM_ROLE '$ROLE_ARN' CSV IGNOREHEADER 1 MAXERROR 100 DATEFORMAT 'auto' TIMEFORMAT 'auto';" \
  --query 'Id' --output text)

DEADLINE=$(( SECONDS + 900 ))   # always bound the loop
while :; do
  STATUS=$(aws redshift-data describe-statement --id "$STMT_ID" \
    --wait-time-seconds 30 --query 'Status' --output text)
  case "$STATUS" in FINISHED|FAILED|ABORTED) break ;; esac
  if (( SECONDS >= DEADLINE )); then echo "COPY still $STATUS after 900s"; exit 1; fi
done

if [[ "$STATUS" != "FINISHED" ]]; then
  echo "COPY failed: $STATUS"
  aws redshift-data describe-statement --id "$STMT_ID" --query 'Error' --output text
  # Row-level diagnostics. The diagnostic SELECT is short, so one long-polled
  # execute-statement is enough — no sleep before fetching results.
  ERR_ID=$(aws redshift-data execute-statement --workgroup-name "$WORKGROUP" --database "$DB" \
    --wait-time-seconds 30 \
    --sql "SELECT file_name, line_number, column_name, error_message FROM sys_load_error_detail ORDER BY start_time DESC LIMIT 20;" \
    --query 'Id' --output text)
  aws redshift-data get-statement-result --id "$ERR_ID"
  exit 1
fi
echo "COPY succeeded: $STMT_ID"
```

- `MAXERROR 100` fails the load once errors reach 100.
- `sys_load_error_detail` for diagnostics (all deployment types).
- `IAM_ROLE` is the **namespace role** (attached to the namespace), not the caller.

## Recipe: Data API poll loop (Python)

```python
import time, boto3

# WaitTimeSeconds (1-30) = long polling: the call returns as soon as the statement
# finishes instead of returning immediately and forcing you to poll. Prefer it —
# fewer calls against a TPS-limited quota, lower latency on short statements. It
# does NOT replace the loop: on expiry the statement may still be running, so
# anything that can exceed 30s still needs a bounded loop.
WAIT = 30

def execute_and_wait(sql, workgroup, database="dev", timeout_s=300):
    # `sql` is sent as-is. Do not build it from unsanitized input: the Data API's
    # Parameters option binds values, not identifiers, so a table or column name
    # taken from user input is a SQL-injection vector. Allowlist identifiers.
    # Region comes from the environment (AWS_REGION / AWS_DEFAULT_REGION) or your
    # profile — set it there rather than pinning one here.
    client = boto3.client("redshift-data")
    # One call submits AND waits up to WAIT seconds for completion.
    desc = client.execute_statement(
        WorkgroupName=workgroup, Database=database, Sql=sql, WaitTimeSeconds=WAIT
    )
    stmt_id = desc["Id"]

    deadline = time.monotonic() + timeout_s
    while desc["Status"] not in ("FINISHED", "FAILED", "ABORTED"):
        if time.monotonic() >= deadline:
            raise TimeoutError(f"{stmt_id} still {desc['Status']} after {timeout_s}s")
        desc = client.describe_statement(Id=stmt_id, WaitTimeSeconds=WAIT)

    if desc["Status"] != "FINISHED":
        raise RuntimeError(f"{stmt_id} {desc['Status']}: {desc.get('Error', '')}")
    if not desc.get("HasResultSet"):
        return []

    rows, kwargs = [], {"Id": stmt_id}
    while True:
        r = client.get_statement_result(**kwargs)
        cols = [c["name"] for c in r["ColumnMetadata"]]
        rows.extend([[None if "isNull" in f else list(f.values())[0] for f in rec] for rec in r["Records"]])
        if "NextToken" not in r:
            break
        kwargs["NextToken"] = r["NextToken"]
    return [dict(zip(cols, row)) for row in rows]
```

`GetStatementResult` also accepts `WaitTimeSeconds`, but its expiry behaviour differs
from the others: instead of reporting an in-progress status it raises
`ResourceNotFoundException` — meaning "no results YET", not "results gone". Treating it
as a failure reports a false error on a still-running query, so catch and retry:

```python
def wait_for_result(client, stmt_id, timeout_s=300):
    deadline = time.monotonic() + timeout_s
    while True:
        try:
            return client.get_statement_result(Id=stmt_id, WaitTimeSeconds=WAIT)
        except client.exceptions.ResourceNotFoundException:
            if time.monotonic() >= deadline:
                raise TimeoutError(f"{stmt_id}: no results after {timeout_s}s")
```

- Target: **Serverless** = `WorkgroupName`, **Provisioned** = `ClusterIdentifier`; plus `Database` either way. Auth is independent of that choice — e.g. temporary credentials (add `DbUser` to connect to a cluster as a database user), Secrets Manager (`SecretArn`), or IAM Identity Center. On the CLI these are `--workgroup-name` / `--cluster-identifier`, `--database`, and `--db-user` or `--secret-arn`.
  `DbUser` issues temporary credentials via `GetClusterCredentials` rather than using a
  stored password. Rotate the secret when using `SecretArn`.
- Log the API calls: `redshift-data:*` actions land in CloudTrail, and cluster-side
  activity needs Redshift audit logging (`useractivitylog`, `connectionlog`, `userlog`)
  enabled separately — CloudTrail alone does not record the SQL that ran.
- `GetStatementResult` returns a result set to anyone who can call it with the statement
  ID, so avoid selecting PII or secret columns into a result set you do not need.
  `sys_load_error_detail` exposes rejected rows in `raw_line`/`err_reason`.
- Throttle = HTTP **400** (not 429). ExecuteStatement TPS is quota-limited — check the Data API quotas page.
- Calls are async by default; **`WaitTimeSeconds` (1-30) turns
  any call into a long poll** that returns when the statement finishes or the wait
  expires, whichever is first. Prefer it over blind sleeping, but keep a bounded loop
  for work that can exceed 30s. Supported on `ExecuteStatement`,
  `BatchExecuteStatement`, `DescribeStatement`, `GetStatementResult`, and
  `GetStatementResultV2`.
- **Expiry behaviour differs by operation** (verified live): `ExecuteStatement` /
  `DescribeStatement` return the current in-progress status, but `GetStatementResult`
  raises **`ResourceNotFoundException`** (`Query does not have result. Please check
  query status with DescribeStatement.`). That means "no results yet", NOT "results
  gone" — catch and retry rather than reporting a failure.
- `BatchExecuteStatement` + `WaitTimeSeconds` holds until **every** sub-statement
  completes, returning the batch parent id and overall status. To wait on one
  sub-statement, long-poll `DescribeStatement`/`GetStatementResult` with that
  sub-statement's id — it returns as soon as that one finishes, without waiting for the
  rest of the batch.

SHA-256: c72c65f4e423f520ba98cf7c0d8f8a5f332131a359e529d4dfd7bc387a475d93