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.
On this page
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.