The Aurora DSQL Guide

The Aurora DSQL constraints reference

DSQL speaks the PostgreSQL wire protocol, so your driver connects and your first SELECT works. The refusals start when you write a schema. This is the list, what to write instead, and — more usefully — the handful of consequences that escape the schema file and end up in your application code.

The schema table #

Verified against live clusters, not read off a feature matrix.

Feature Status Write this instead
FOREIGN KEY Not supported Nothing. Referential integrity is the application’s job now
SERIAL, sequences Not supported gen_random_uuid(), or client-generated ULIDs
CREATE INDEX Rejected CREATE INDEX ASYNC
DESC in an index key Not supported Nothing — the planner scans the index backwards
TEXT[] and other array types Not supported JSONB
ALTER TABLE ADD COLUMN with a constraint Nullable and unconstrained only Declare constrained columns in the original CREATE TABLE
ALTER TABLE DROP COLUMN Not supported at all Nothing. See consequence 3
INSERT ... ON CONFLICT Not supported Insert and catch 23505. See consequence 1
SELECT ... FOR UPDATE Equality predicates on the key only Usually nothing — DSQL is optimistic, so you were not getting a lock anyway
CREATE EXTENSION Not supported Nothing. No pg_trgm, no citext, no PostGIS, no pgvector
Triggers Not supported Application code, or a change-data pipeline
Views, materialized views Not supported Queries in the application; a table you maintain yourself
Stored procedures, PL/pgSQL Not supported Application code
Full system catalog access Partial Expect admin tooling and schema introspectors to misbehave

The extension row is the one that surprises people most, because it is not one missing feature — it is the entire PostgreSQL ecosystem. pgvector in particular: Aurora PostgreSQL has it, Aurora DSQL does not, and the two products being one word apart causes a great deal of confusion. If you need semantic search, it lives outside DSQL.

The transaction limits #

These are AWS’s documented limits rather than things you can discover by experiment without trying to exceed them. As of August 2026:

Limit Value
Rows modified per transaction 3,000
Data modified per transaction 10 MiB
Transaction duration 5 minutes
Connection duration 1 hour

The row limit is the one that quietly rules out whole categories of application. A nightly job that rewrites a 200,000-row table is not a large job by any normal standard, and in DSQL it is 67 transactions that you have to chunk, checkpoint, and make resumable yourself. If your workload has bulk in it, price that work in before you commit to the database.

The one-hour connection cap matters for a different reason: it interacts with IAM authentication, where the token you connect with is itself short-lived. A long-running process cannot open a pool at boot and forget about it. That subject deserves its own chapter and will get one.

Consequence 1: every upsert is insert-and-catch #

No ON CONFLICT means the idiomatic PostgreSQL upsert is unavailable, and there is no clever rewrite that gets it back. What replaces it is a try/catch on SQLSTATE 23505:

// Create the user on first sign-in. Two requests can race here — both see no
// row, both insert, one loses. Losing is fine and expected; it is not an error
// condition, it is the other request having gotten there first.
async function ensureUser(client, email) {
  try {
    const { rows } = await client.query(
      `INSERT INTO users (id, email) VALUES (gen_random_uuid(), $1) RETURNING *`,
      [email]
    );
    return rows[0];
  } catch (err) {
    if (err.code !== '23505') throw err;
    const { rows } = await client.query(`SELECT * FROM users WHERE email = $1`, [email]);
    return rows[0];
  }
}

Two things about this pattern are easy to get wrong.

Catch the code, not the message. err.code === '23505' is stable. Matching on the text of the error breaks the first time AWS rewords it.

Re-select after the catch. The row exists — the whole reason you are in the catch block — but you do not have it. Returning null here produces a bug that only appears under concurrency, which is to say only in production.

Anything that would have been an upsert becomes this. Re-inviting a guest, claiming a short id, recording a webhook you may have already seen. In practice you write it once as a helper and then stop thinking about it, but you do have to write it.

Consequence 2: unique indexes do not enforce while they build #

CREATE UNIQUE INDEX ASYNC returns immediately and the index builds in the background. Until that build finishes, the uniqueness constraint is not being enforced — and a duplicate inserted during the window is not rejected later. It stays. The index finishes building around it, and now you have a unique index over non-unique data.

There are two defenses, and you want both.

Prefer a PRIMARY KEY where you can. A primary key is enforced from the first insert, with no build window at all. If a natural or composite key exists, use it rather than a surrogate id plus a unique index:

-- Enforced immediately. There is no window.
CREATE TABLE short_ids (
  workspace_id uuid NOT NULL,
  short        text NOT NULL,
  thread_id    uuid NOT NULL,
  PRIMARY KEY (workspace_id, short)
);

Create the remaining unique indexes on an empty cluster. Before there is traffic, the build window is harmless. This is a strong argument for getting your indexes into schema.sql rather than discovering you need one at month four, when the safe way to add it involves a maintenance window you did not think this database required.

Consequence 3: adding a column is permanent #

There is no DROP COLUMN. Not “it is slow”, not “it requires a rewrite” — it is not implemented. A column you add is a column that table has forever.

This inverts the usual migration instinct. In PostgreSQL, adding a column speculatively is close to free because you can drop it later. In DSQL, every ADD COLUMN is a permanent decision made under whatever amount of thought you gave it that afternoon.

It compounds with the other half of the rule: ADD COLUMN cannot carry a constraint, so the new column is nullable and unconstrained, always. Anything that must be NOT NULL has to be declared in the original CREATE TABLE. “We will tighten it up later” is not available — later does not exist.

The practical consequence is that schema.sql deserves more design time than you are used to giving it, and that forward-only migration files are the only migration story here. There is no down migration, because down is not a direction this database moves.

What this rules out entirely #

Read as a whole rather than row by row, the table says something the individual entries do not.

Migrating an existing PostgreSQL application is a rewrite. Not a port. A mature schema has foreign keys, a few triggers, probably a view, likely an extension, and a sequence somewhere. Every one of those is a redesign, and the ones that hurt most — triggers and foreign keys — move logic out of the database and into application code that does not exist yet.

Anything with real bulk in it needs a second system. Analytics rollups, imports, backfills, nightly recomputation. All possible in 3,000-row chunks; none pleasant.

Semantic search has to live elsewhere. No pgvector, no vector type, no extension mechanism to add one. This is worth knowing early, because it is now a normal expectation for a new product, and the answer involves a second data store.

None of this makes DSQL a bad database. It makes it a specific one, aimed at applications written from scratch that want relational modeling and global consistency without operating anything. If that is the application you are writing, the constraints are mostly a schema-design exercise you do once. If it is not, you want to find that out now rather than in week three.