The columnar model page explained why Redshift is a warehouse and not a transactional database. This page turns that into working code: stand up the smallest possible Redshift Serverless warehouse, create a table, load a few rows, and run a query - entirely through the SDK, with no JDBC or psql connection anywhere.
We use Redshift Serverless because it is the fastest thing to provision from an SDK: no node types, no cluster sizing, just a namespace and a workgroup. Every SQL statement travels over the Redshift Data API, which is asynchronous - you submit a statement, get an id, poll until it finishes, then read the rows.
Credentials configured via aws configure, environment variables, or an IAM role - the SDK resolves them automatically.
A region set with AWS_REGION or passed per client. These examples assume us-east-1.
IAM permissions for redshift-serverless:* on the namespace/workgroup and redshift-data:* for queries. Serverless also needs a default VPC (or subnet ids) to place the workgroup.
A namespace holds the database, its admin credentials, and the IAM roles Redshift can assume for loading data. It is storage-and-identity, with no compute of its own.
# --- Python (boto3) ---serverless.create_namespace( namespaceName="analytics-ns", adminUsername="admin", adminUserPassword="ChangeMe-123!", # or omit and use adminPasswordSecretKmsKeyId dbName="dev",)
// --- TypeScript (AWS SDK v3) ---import { CreateNamespaceCommand } from "@aws-sdk/client-redshift-serverless";await serverless.send(new CreateNamespaceCommand({ namespaceName: "analytics-ns", adminUsername: "admin", adminUserPassword: "ChangeMe-123!", // or omit and let Redshift manage it in Secrets Manager dbName: "dev",}));
The namespace is where data lives; it survives even when no workgroup is attached.
adminUsername/adminUserPassword seed the database superuser - prefer a Secrets Manager-managed secret in production.
Attach data-loading permissions later with iamRoles so COPY can read S3.
One namespace can be paired with one workgroup at a time in the simple case.
A workgroup is the compute half: it sets the base capacity in RPUs and the network placement, and it binds to a namespace. Creating it is what makes the warehouse queryable.
# --- Python (boto3) ---serverless.create_workgroup( workgroupName="analytics-wg", namespaceName="analytics-ns", baseCapacity=8, # RPUs; 8 is the minimum, scales up per query publiclyAccessible=False,)# Wait until status is AVAILABLE before querying.
// --- TypeScript (AWS SDK v3) ---import { CreateWorkgroupCommand } from "@aws-sdk/client-redshift-serverless";await serverless.send(new CreateWorkgroupCommand({ workgroupName: "analytics-wg", namespaceName: "analytics-ns", baseCapacity: 8, // RPUs; 8 is the minimum, scales up per query publiclyAccessible: false,}));// Wait until status is AVAILABLE before querying.
baseCapacity is the floor of compute in RPUs (8 to 512, in steps of 8); Redshift scales above it for heavy queries.
You pay per second of RPU usage while queries run, and nothing when the workgroup is idle.
Workgroup creation takes a couple of minutes; poll get_workgroup until status is AVAILABLE.
publiclyAccessible=False keeps the endpoint private; the Data API still reaches it over the AWS network.
With the workgroup available, the first SQL statement creates a table. Send it through the Data API as a plain string - there is no connection to open.
# --- Python (boto3) ---resp = data.execute_statement( WorkgroupName="analytics-wg", Database="dev", Sql=""" CREATE TABLE IF NOT EXISTS sales ( id BIGINT, region VARCHAR(32), amount DECIMAL(10,2), sold_at TIMESTAMP ) DISTSTYLE AUTO SORTKEY (sold_at) """,)print("statement id:", resp["Id"]) # DDL is async too - poll before the next step
// --- TypeScript (AWS SDK v3) ---import { ExecuteStatementCommand } from "@aws-sdk/client-redshift-data";const resp = await data.send(new ExecuteStatementCommand({ WorkgroupName: "analytics-wg", Database: "dev", Sql: ` CREATE TABLE IF NOT EXISTS sales ( id BIGINT, region VARCHAR(32), amount DECIMAL(10,2), sold_at TIMESTAMP ) DISTSTYLE AUTO SORTKEY (sold_at) `,}));console.log("statement id:", resp.Id); // DDL is async too - poll before the next step
ExecuteStatement returns immediately with an Id; the statement runs on the warehouse.
Single-row inserts are fine for a handful of demo rows (bulk loads use COPY - see the loading page). The key skill is the poll loop: after ExecuteStatement, call DescribeStatement until Status is FINISHED.
# --- Python (boto3) ---import timestart = data.execute_statement( WorkgroupName="analytics-wg", Database="dev", Sql="INSERT INTO sales VALUES " "(1,'us-east',42.00,'2026-01-05'), (2,'us-west',17.50,'2026-01-06')",)stmt_id = start["Id"]# Poll until the statement finishes.while True: desc = data.describe_statement(Id=stmt_id) status = desc["Status"] # SUBMITTED -> STARTED -> FINISHED if status in ("FINISHED", "FAILED", "ABORTED"): break time.sleep(1)if status != "FINISHED": raise RuntimeError(desc.get("Error", status))print("inserted rows")
// --- TypeScript (AWS SDK v3) ---import { ExecuteStatementCommand, DescribeStatementCommand } from "@aws-sdk/client-redshift-data";const start = await data.send(new ExecuteStatementCommand({ WorkgroupName: "analytics-wg", Database: "dev", Sql: "INSERT INTO sales VALUES " + "(1,'us-east',42.00,'2026-01-05'), (2,'us-west',17.50,'2026-01-06')",}));const stmtId = start.Id!;// Poll until the statement finishes.let status = "";for (;;) { const desc = await data.send(new DescribeStatementCommand({ Id: stmtId })); status = desc.Status!; // SUBMITTED -> STARTED -> FINISHED if (["FINISHED", "FAILED", "ABORTED"].includes(status)) { if (status !== "FINISHED") throw new Error(desc.Error ?? status); break; } await new Promise((r) => setTimeout(r, 1000));}console.log("inserted rows");
DescribeStatement moves through SUBMITTED, PICKED, STARTED, then FINISHED (or FAILED/ABORTED).
Always check for the terminal failure states and surface Error - a bad SQL string fails here, not at submit time.
One second is a reasonable poll interval for interactive work; back off for long-running loads.
Use Parameters on ExecuteStatement for value binding instead of string-concatenating user input.
Submit a SELECT, poll to FINISHED, then call GetStatementResult to page through the rows. Results come back as typed cells you unwrap.
# --- Python (boto3) ---import timeq = data.execute_statement( WorkgroupName="analytics-wg", Database="dev", Sql="SELECT region, SUM(amount) AS total FROM sales GROUP BY region ORDER BY total DESC",)qid = q["Id"]while data.describe_statement(Id=qid)["Status"] not in ("FINISHED", "FAILED", "ABORTED"): time.sleep(1)result = data.get_statement_result(Id=qid)for row in result["Records"]: # Each cell is a typed dict: {"stringValue": ...} / {"doubleValue": ...} region = row[0]["stringValue"] total = row[1]["doubleValue"] print(region, total)
// --- TypeScript (AWS SDK v3) ---import { ExecuteStatementCommand, DescribeStatementCommand, GetStatementResultCommand,} from "@aws-sdk/client-redshift-data";const q = await data.send(new ExecuteStatementCommand({ WorkgroupName: "analytics-wg", Database: "dev", Sql: "SELECT region, SUM(amount) AS total FROM sales GROUP BY region ORDER BY total DESC",}));const qid = q.Id!;let s = "";do { s = (await data.send(new DescribeStatementCommand({ Id: qid }))).Status!; if (["FINISHED", "FAILED", "ABORTED"].includes(s)) break; await new Promise((r) => setTimeout(r, 1000));} while (true);const result = await data.send(new GetStatementResultCommand({ Id: qid }));for (const row of result.Records ?? []) { // Each cell is a typed union: { stringValue } / { doubleValue } const region = row[0].stringValue; const total = row[1].doubleValue; console.log(region, total);}
GetStatementResult only works after the statement reaches FINISHED and only when it produced a result set.
Each cell is a tagged value (stringValue, longValue, doubleValue, isNull); read the field matching the column type.
Large results paginate: pass the returned NextToken back until it is absent.
ColumnMetadata in the response gives column names and types if you need to map by name.
Serverless splits storage-and-identity (the namespace) from compute-and-network (the workgroup) so the two scale and bill independently. Your data lives in the namespace continuously; the workgroup only consumes RPUs while queries run. You can delete and recreate a workgroup, or point a new one at an existing namespace, without touching the data. This is the serverless mirror of a provisioned cluster, where those concerns are fused into one node group.
Every statement is asynchronous. ExecuteStatement enqueues SQL and hands back an Id before the query runs, so there is no blocking connection and no idle timeout to manage - ideal for Lambda. The cost is that you must poll DescribeStatement, and result retrieval is a separate call. For multiple statements in one transaction, BatchExecuteStatement submits an array of SQL strings under one id. To be notified instead of polling, set WithEvent=True and Redshift emits an EventBridge event when the statement finishes.
The examples target serverless with WorkgroupName, which authenticates via the caller's IAM identity and the namespace's admin - no database password in the call. Against a provisioned cluster you choose between DbUser (Redshift issues temporary credentials for that user) and SecretArn (a Secrets Manager secret holding the database login). Either way, no long-lived password travels in your code.
What is the difference between a namespace and a workgroup?
A namespace is storage and identity - the database, admin credentials, and IAM roles. A workgroup is compute and network - base RPU capacity and where it runs. Data lives in the namespace; the workgroup queries it and is billed only while active.
Do I need a JDBC connection for these examples?
No. Every statement goes through the Redshift Data API, which is connectionless: you submit SQL, receive a statement id, poll DescribeStatement until it finishes, and fetch results with GetStatementResult. Nothing opens a persistent connection.
Why does ExecuteStatement not return my query results?
Because it is asynchronous. It enqueues the SQL and returns an Id immediately. You then poll DescribeStatement until Status is FINISHED and, for a SELECT, call GetStatementResult to read the rows.
What is baseCapacity measured in?
Redshift Processing Units (RPUs), from 8 to 512 in steps of 8. It sets the floor of compute the workgroup keeps ready; Redshift scales above it for demanding queries and bills per second of RPU usage, with nothing charged while idle.
How do I read a typed result cell?
Each cell in Records is a tagged value: stringValue, longValue, doubleValue, booleanValue, or isNull. Read the field that matches the column's type. ColumnMetadata in the same response gives the column names and types.
Can I use the same code against a provisioned cluster?
Almost. Swap WorkgroupName for ClusterIdentifier and add either DbUser (for temporary credentials) or SecretArn (for a Secrets Manager login). The ExecuteStatement / DescribeStatement / GetStatementResult flow is identical.
How do I load a lot of data quickly?
Not with INSERT. Put your data in S3 and run a COPY statement through the Data API, which loads files in parallel across the warehouse's slices. The loading page covers COPY and Redshift Spectrum in full.
How do I avoid polling in a loop?
Set WithEvent=true on ExecuteStatement and Redshift publishes an EventBridge event when the statement reaches a terminal state. You can trigger a Lambda or Step Functions task off that event instead of polling DescribeStatement.