Data Definition Language statements for creating and managing database objects.
CREATE TABLE [IF NOT EXISTS] table_name (
column_name type [constraints],
-- Create from query result
CREATE TABLE new_table AS SELECT ... FROM source;
SELECT ... INTO new_table FROM source;
Column constraints: NOT NULL, DEFAULT, PRIMARY KEY, UNIQUE, REFERENCES (foreign key), CHECK
Table constraints: PRIMARY KEY (col, ...), UNIQUE (col, ...), FOREIGN KEY (col) REFERENCES table(col) with ON DELETE/ON UPDATE actions, CHECK (expr)
SERIAL and BIGSERIAL columns are supported for auto-incrementing IDs. Self-referential foreign keys are supported.
CREATE TABLE AS adds a _rowid primary key column
Tables created by CREATE TABLE ... AS SELECT or SELECT ... INTO get an extra internal
_rowid column prepended to the projected columns. It is visible in SELECT * and in
information_schema.columns, so SELECT * returns one more column than PostgreSQL would:
CREATE TABLE ctas AS SELECT 1 AS a, 'x' AS b;
SELECT * FROM ctas; -- returns _rowid, a, b
CREATE TABLE tgt (a INT , b TEXT );
INSERT INTO tgt SELECT * FROM ctas;
-- ERROR: INSERT has more target columns than expressions (2 columns, 3 values) (42601)
The error message’s wording is inverted — the surplus value is _rowid.
_rowid becomes the table’s primary key and cannot be removed afterwards
(ALTER TABLE ctas DROP COLUMN _rowid fails with Cannot drop primary key column '_rowid').
Selecting columns explicitly (SELECT a, b FROM ctas) avoids the problem per query; to avoid the
column entirely, create the table first and populate it separately:
CREATE TABLE clean (a INT , b TEXT );
INSERT INTO clean SELECT a, b FROM source;
Tables created with a plain CREATE TABLE are unaffected, with or without a primary key.
Identity columns:
-- GENERATED ALWAYS AS IDENTITY (database controls the value)
CREATE TABLE t (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY );
-- GENERATED BY DEFAULT AS IDENTITY (user can override)
CREATE TABLE t (id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY );
Identity columns ignore sequence options
Identity columns always start at 1 and increment by 1. Sequence options in an identity definition
either fail to parse or have no effect:
More than one option is a parse error — the PostgreSQL form
IDENTITY (START WITH 100 INCREMENT BY 10) fails with
SQL parse error: Expected ), found: INCREMENT (42601). The comma-separated form fails too.
A single option such as IDENTITY (START WITH 100) or IDENTITY (MINVALUE 5) is accepted,
but silently ignored — the generated values are still 1, 2, 3, …. This applies to all of
START, INCREMENT, MINVALUE, MAXVALUE, CACHE, and CYCLE.
For a specific starting value or step, use an explicit sequence — CREATE SEQUENCE does honor
START and INCREMENT:
CREATE SEQUENCE order_seq START WITH 1000 INCREMENT BY 5 ;
CREATE TABLE orders (id BIGINT PRIMARY KEY DEFAULT nextval( 'order_seq' ), tag TEXT );
-- ids: 1000, 1005, 1010, …
Generated (computed) columns:
total NUMERIC GENERATED ALWAYS AS (price + tax) STORED
-- B-tree index (default)
CREATE INDEX idx_name ON table (column);
CREATE UNIQUE INDEX idx_name ON table (column);
-- GIN index (for JSONB, full-text search, arrays)
CREATE INDEX idx_name ON table USING GIN (column);
CREATE INDEX idx_name ON table ( lower (column));
CREATE INDEX idx_name ON table (column) WHERE active = true;
-- Non-blocking index creation
CREATE INDEX CONCURRENTLY idx_name ON table (column);
Only btree (default), gin, and hnsw are recognized index access methods, and all three can
be created — see the pgvector page for HNSW.
IF NOT EXISTS is supported.
Other index access methods are rejected
GiST, Hash, SP-GiST, and BRIN are rejected at CREATE INDEX time — they are not
accepted-then-ignored:
ERROR: access method "gist" is not supported (0A000)
HINT: Only btree, gin, and hnsw indexes are currently supported.
hnsw builds vector indexes and has extra structural requirements of its own — see
pgvector and the
compatibility matrix .
GIN index example (JSONB containment):
CREATE TABLE documents (id SERIAL PRIMARY KEY , metadata JSONB);
CREATE INDEX idx_metadata ON documents USING GIN (metadata);
-- Use the @> containment operator (accelerated by GIN)
SELECT * FROM documents WHERE metadata @> '{"type": "pdf"}' ;
GIN indexes support JSONB containment (@>) and full-text search (@@) operators. See Full-Text Search for tsvector usage.
CREATE VIEW view_name AS SELECT ...;
CREATE OR REPLACE VIEW view_name AS SELECT ...;
CREATE OR REPLACE VIEW cannot drop or rename existing columns; only appending new columns is allowed.
CREATE MATERIALIZED VIEW mv_name AS SELECT ...;
REFRESH MATERIALIZED VIEW mv_name;
Operation Syntax Add column ALTER TABLE t ADD COLUMN col typeDrop column ALTER TABLE t DROP COLUMN colRename column ALTER TABLE t RENAME COLUMN old TO newRename table ALTER TABLE t RENAME TO new_nameSet default ALTER TABLE t ALTER COLUMN col SET DEFAULT valDrop default ALTER TABLE t ALTER COLUMN col DROP DEFAULTSet not null ALTER TABLE t ALTER COLUMN col SET NOT NULLDrop not null ALTER TABLE t ALTER COLUMN col DROP NOT NULLChange type ALTER TABLE t ALTER COLUMN col SET DATA TYPE type [USING expr] — not on primary-key or foreign-key referencing columns, see belowAdd constraint ALTER TABLE t ADD CONSTRAINT name ...Drop constraint ALTER TABLE t DROP CONSTRAINT nameRename constraint ALTER TABLE t RENAME CONSTRAINT old TO new
A primary-key column, or a foreign key’s referencing column, cannot change type
ALTER COLUMN … TYPE fails with a bare internal error (XX000) whenever the column is part of
the primary key or is the referencing column of a foreign key, whether the change widens or
narrows the type:
CREATE TABLE parent (code VARCHAR ( 10 ) PRIMARY KEY );
CREATE TABLE child (id INT PRIMARY KEY , pcode VARCHAR ( 10 ) REFERENCES parent(code));
ALTER TABLE parent ALTER COLUMN code TYPE VARCHAR ( 20 ); -- ERROR: internal error (XX000)
ALTER TABLE child ALTER COLUMN pcode TYPE VARCHAR ( 20 ); -- ERROR: internal error (XX000)
The column’s role in a key is what matters, not the type and not the rest of the table. Measured
on both the wire protocol and the HTTP SQL API:
Column participates in ALTER COLUMN … TYPENothing Works PRIMARY KEY (single-column)XX000PRIMARY KEY (one member of a composite key)XX000FOREIGN KEY (the referencing column)XX000FOREIGN KEY (the referenced column, when it is the primary key)XX000FOREIGN KEY (the referenced column, when it is UNIQUE but not the primary key)Works — the foreign key still enforces (23503) UNIQUE constraintWorks A non-unique index Works CHECK constraintWorks NOT NULLWorks Nothing, in a table that has a key elsewhere Works
So UNIQUE columns change type freely while primary keys do not, even though both are
index-backed — and that holds when a foreign key points at the UNIQUE column. A non-key column is
unaffected by keys elsewhere in the same table.
To change a key column’s type, recreate the table: create the replacement with the new types,
copy the rows, then drop and rename. Drop the foreign keys first — and see
the note on DROP TABLE and pg_constraint before dropping a referenced table.
DROP TABLE [IF EXISTS] table_name [CASCADE];
DROP INDEX [IF EXISTS] index_name;
DROP VIEW [IF EXISTS] view_name [CASCADE];
DROP MATERIALIZED VIEW [IF EXISTS] mv_name [CASCADE];
TRUNCATE TABLE table_name;
CASCADE transitively resolves and drops all dependent views and materialized views.
TRUNCATE t and TRUNCATE TABLE t are the only accepted spellings. Every optional clause is
rejected at parse time (42601) — the multi-table form TRUNCATE a, b, plus CASCADE, RESTRICT,
ONLY, RESTART IDENTITY and CONTINUE IDENTITY (which is PostgreSQL’s default, so it is only
ever written explicitly).
TRUNCATE on a table that another table’s foreign key references is refused, as in PostgreSQL,
with 0A000 (cannot truncate a table referenced in a foreign key constraint) — even when the
referencing table is empty. Because the multi-table form and CASCADE are unavailable, empty the
referencing table first and remove the parent’s rows with DELETE, or drop the foreign key before
truncating — see Compatibility matrix .
Views are guarded; foreign keys are not
A dependent view or materialized view blocks the drop, with the same 2BP01 PostgreSQL
raises. Pass CASCADE to remove the dependents along with the table:
CREATE VIEW dv1 AS SELECT * FROM d1;
-- ERROR: cannot drop relation "public.d1" because other objects depend on it;
-- use DROP ... CASCADE to drop the dependent objects too (2BP01)
DROP TABLE d1 CASCADE; -- succeeds, and drops dv1 with it
Check for inbound foreign keys before reaching for CASCADE, though: the dependency check that
produced that 2BP01 does not cover them, so on a table that has both a view and a referencing
foreign key, CASCADE clears the view and leaves the constraint dangling — with the consequences
below.
A foreign key from another table is not covered by that check. The drop succeeds, and the
orphaned constraint then breaks constraint introspection for the entire database — not only for
the table that carries it:
CREATE TABLE parent (id INT PRIMARY KEY );
CREATE TABLE child (id INT PRIMARY KEY , pid INT REFERENCES parent(id));
DROP TABLE parent; -- succeeds; PostgreSQL refuses with 2BP01
SELECT count (*) FROM pg_constraint; -- ERROR: internal error (XX000)
INSERT INTO child VALUES ( 1 , 1 ); -- ERROR: internal error (XX000)
CASCADE does not help — the constraint is left dangling either way.
On the orphaned child Fails with XX000 any pg_constraint scan (database-wide, including for unrelated tables); INSERT; UPDATE; CREATE INDEX; DROP INDEX; every ALTER TABLE form except DROP CONSTRAINT; information_schema.referential_constraints and constraint_column_usage Still works SELECT (including FOR UPDATE), DELETE, TRUNCATE, CREATE VIEW, CREATE MATERIALIZED VIEW, CREATE TRIGGER, COMMENT ON, ALTER TABLE … DROP CONSTRAINT, and information_schema.table_constraints
The DDL entries are what bite in practice: an ORM migration adding a column dies with an opaque
internal error and nothing points at the dropped table as the cause.
Dropping the constraint fixes it, and works equally well as prevention or as cure — before the
drop, or afterwards to recover a database already in this state, without losing child:
ALTER TABLE child DROP CONSTRAINT child_pid_fkey;
SELECT count (*) FROM pg_constraint; -- works again
Every constraint that pointed at the dropped table has to go. If two tables referenced it,
removing one leaves the database just as broken, and pg_constraint gives you no signal that you
are only half done — it fails identically either way. information_schema.table_constraints still
works and will list the foreign keys, though it will not tell you which target is missing:
SELECT table_name, constraint_name
FROM information_schema.table_constraints
WHERE constraint_type = 'FOREIGN KEY' ;
DROP CONSTRAINT only works while a table carries exactly one dangling constraint. If a single
table has two foreign keys into the dropped table — a link table, or one whose two parents were
both dropped — the ALTER TABLE fails with the same internal error, and that table has to be
dropped outright.
Dropping the referencing tables outright also clears it, but takes them and their rows with them.
Statement Supported CREATE SCHEMAYes CREATE SEQUENCE / DROP SEQUENCEYes ALTER SEQUENCEOwnership forms only — see below CREATE TYPE (enum, composite)Yes — but enum values sort by label text, and composite values cannot be read field-wise; see Custom Types and the composite type caution ALTER TYPEEnum values and RENAME TO only — see below DROP TYPEYes — but CASCADE does not cascade, and some dependents are not checked; see below CREATE FUNCTION / DROP FUNCTIONYes CREATE TRIGGER / DROP TRIGGERYes CREATE COLLATION / DROP COLLATIONYes CREATE DATABASE / DROP DATABASEYes ALTER DATABASERENAME TO and OWNER TO only — see belowCREATE EXTENSION / DROP EXTENSIONYes COMMENT ON (table, column, function, extension, index, schema)Yes
COMMENT ON accepts only six object types
TABLE, COLUMN, FUNCTION, EXTENSION, INDEX, and SCHEMA are supported. Every other
target tested is rejected — 28 of them: VIEW, MATERIALIZED VIEW, SEQUENCE, TYPE, DATABASE,
CONSTRAINT, TRIGGER, COLLATION, AGGREGATE, DOMAIN, POLICY, PROCEDURE,
PUBLICATION, ROLE, RULE, OPERATOR, CAST, LANGUAGE, STATISTICS, TABLESPACE,
SERVER, FOREIGN TABLE, LARGE OBJECT, ACCESS METHOD, and all four TEXT SEARCH targets
(CONFIGURATION, DICTIONARY, PARSER, TEMPLATE).
A rejection is always a bare, unhelpful internal error whose SQLSTATE is the only meaningful
part:
ERROR: internal error (0A000)
0A000 means the object type is unsupported — it does not mean your object is missing.
The type is checked before the object is resolved, so 0A000 comes back even for a name that
does not exist. When the type is supported and the object is not found you get a different
code, and only some of them are useful:
Missing object SQLSTATE COLUMN42703 undefined_columnINDEX42P01 undefined_tableSCHEMA3F000 invalid_schema_nameTABLE, FUNCTION, EXTENSIONXX000 — bare internal error again
That last row is the trap. COMMENT ON EXTENSION <not-installed> returns XX000 with the same
message as a rejection, so it reads as though EXTENSION were an unsupported type. It is not —
check pg_extension before concluding otherwise. The reliable rule is that 0A000 is the only
code meaning “unsupported object type”.
Where the statement does succeed, the comment is written to pg_description, but
OBJ_DESCRIPTION() and COL_DESCRIPTION() still return NULL — read pg_description
directly. See description functions .
Sequence options: START, INCREMENT, MINVALUE, MAXVALUE, CACHE, CYCLE are accepted in CREATE SEQUENCE (START and INCREMENT are confirmed to take effect). In identity column definitions a single option is accepted but ignored, and multiple options fail to parse — see Identity columns above.
ALTER SEQUENCE supports only the ownership forms
These three are accepted and take effect:
ALTER SEQUENCE order_seq OWNER TO app_user; -- changes pg_class.relowner
ALTER SEQUENCE order_seq OWNED BY orders.id; -- sequence is dropped with the table
ALTER SEQUENCE order_seq OWNED BY NONE ; -- clears it; sequence survives the table
Every other form is rejected with the same bare message, where only the SQLSTATE is meaningful:
ERROR: internal error (0A000)
That covers the parameter clauses (RESTART, RESTART WITH, INCREMENT BY, START WITH,
MINVALUE, NO MINVALUE, MAXVALUE, NO MAXVALUE, CACHE, CYCLE, NO CYCLE), the type
and storage clauses (AS, SET LOGGED, SET UNLOGGED), and RENAME TO and SET SCHEMA.
A sequence’s parameters, name, and schema are therefore all fixed for its lifetime.
To reposition a sequence, use SETVAL() instead of
RESTART:
SELECT SETVAL( 'order_seq' , 1000 ); -- next NEXTVAL returns 1001 (at INCREMENT 1)
SELECT SETVAL( 'order_seq' , 1000 , false); -- next NEXTVAL returns 1000
Changing INCREMENT or the bounds requires dropping and recreating the sequence — but read the
warning below first if anything already references it.
DROP SEQUENCE does not check column defaults
Unlike PostgreSQL, DB9 does not refuse to drop a sequence that a column default still
depends on. The DROP succeeds silently and leaves the table broken:
CREATE TABLE ref_t (id BIGINT PRIMARY KEY DEFAULT nextval( 'ref_s' ), tag TEXT );
DROP SEQUENCE ref_s; -- succeeds; PostgreSQL would refuse
INSERT INTO ref_t (tag) VALUES ( 'x' ); -- ERROR: relation "public.ref_s" does not exist (42P01)
PostgreSQL would reject that DROP with “cannot drop sequence ref_s because other objects
depend on it” .
SERIAL columns are affected the same way, which is the more common case — the sequence
CREATE TABLE t (id SERIAL …) creates implicitly can be dropped just as freely:
CREATE TABLE ser_t (id SERIAL PRIMARY KEY , tag TEXT );
DROP SEQUENCE ser_t_id_seq; -- succeeds
INSERT INTO ser_t (tag) VALUES ( 'x' ); -- ERROR: relation "public.ser_t_id_seq" does not exist (42P01)
So check for column defaults, SERIAL, and identity columns before dropping a sequence. If you
are recreating one to change its parameters, recreate it under the same name and restore its
position with SETVAL() — reading last_value first, so you do not hand out duplicate keys:
SELECT last_value FROM order_seq; -- note this before dropping
-- ... DROP SEQUENCE / CREATE SEQUENCE with the new parameters ...
SELECT SETVAL( 'order_seq' , <last_value>); -- restore the position
ALTER SEQUENCE order_seq OWNED BY orders.id; -- re-establish the link, if it had one
That last line matters: a recreated sequence has no OWNED BY link, so without it the sequence
will outlive the table it belongs to.
ALTER TYPE supports enum values and RENAME TO; DROP TYPE checks only some dependencies
These forms work, and the change is visible in pg_type and pg_enum:
ALTER TYPE mood ADD VALUE 'excited' ; -- also IF NOT EXISTS, BEFORE 'x', AFTER 'x'
ALTER TYPE mood RENAME VALUE 'sad' TO 'blue' ; -- see enum arrays, below
ALTER TYPE mood RENAME TO feeling;
Every other form fails with a bare internal error (XX000): OWNER TO, SET SCHEMA, and all
of the composite-type attribute forms — ADD ATTRIBUTE, DROP ATTRIBUTE,
ALTER ATTRIBUTE … TYPE, and RENAME ATTRIBUTE. A composite type’s shape is therefore fixed once
created; to change it, create a new type and migrate the columns that use it.
RENAME VALUE and enum arrays. RENAME VALUE updates the label in plain enum columns, but
values inside an enum array column (mood[]) keep the old label. That has two consequences:
If one row holds the old label in both a plain column and an array column, the rename itself
fails with 22P02 (invalid input value for enum mood: "sad") and nothing is changed.
After a rename that succeeds, an UPDATE to a row whose array still holds the old label fails
with the same 22P02 — including one that only sets an unrelated column — unless it rewrites
the array itself. DELETE still works.
Rewrite the affected arrays straight after the rename (array_replace() is not available):
UPDATE t SET moods = ARRAY (
SELECT CASE WHEN x = 'sad' THEN 'blue' ELSE x END
FROM unnest(moods:: text []) WITH ORDINALITY AS u(x, n) ORDER BY n
WHERE 'sad' = ANY(moods:: text []);
DROP TYPE dependencies. A plain column of the type, or a function that takes it as an
argument, makes DROP TYPE fail with 2BP01, as in PostgreSQL. Unlike PostgreSQL, adding
CASCADE does not drop those dependents — it fails with the same 2BP01. Two dependencies that
PostgreSQL also protects are not checked at all, and the drop succeeds without an error:
Object still using the dropped type Afterwards A column of its array type (mood[]) INSERT into the table fails with XX000A function that returns it (RETURNS mood, RETURNS mood[]) Calling the function fails with 0A000
Look for both before dropping a type:
SELECT table_name, column_name FROM information_schema.columns WHERE udt_name IN ( 'mood' , '_mood' );
SELECT proname FROM pg_proc WHERE prorettype = 'mood' ::regtype;
DROP TYPE on a name that does not exist returns a bare XX000 rather than 42704, so use
DROP TYPE IF EXISTS when the type may be absent.
ALTER DATABASE accepts only RENAME TO and OWNER TO
Both of those work:
ALTER DATABASE reports RENAME TO reports_v2; -- succeeds
ALTER DATABASE reports_v2 OWNER TO app_user; -- succeeds (42704 if the role does not exist)
Every other form is rejected by the parser , before the database is even resolved:
ERROR: syntax error: sql parser error: Expected RENAME or OWNER after ALTER DATABASE,
That covers SET <param>, RESET <param>, RESET ALL, SET TABLESPACE, CONNECTION LIMIT,
REFRESH COLLATION VERSION, IS_TEMPLATE, and ALLOW_CONNECTIONS. There is therefore no
ALTER DATABASE … SET search_path; set a default through the role instead. ALTER ROLE … SET
stores the setting in pg_db_role_setting and pg_roles.rolconfig, and every new session for
that role picks it up, over both the wire protocol and the HTTP SQL API:
ALTER ROLE app_user SET search_path TO app, public; -- every database
ALTER ROLE app_user IN DATABASE postgres SET search_path TO app, public; -- this database only
ALTER ROLE app_user RESET search_path; -- remove the default
Name the role explicitly. ALTER ROLE CURRENT_USER SET … and ALTER ROLE ALL SET …, which
PostgreSQL accepts, fail here with 42704 (role "current_user" does not exist).
For a single connection, pass the parameter in the libpq options string with a space after
-c — ?options=-c%20search_path%3Dapp in a connection URI, or
PGOPTIONS="-c search_path=app". The compact spelling without the space
(?options=-csearch_path%3Dapp), which PostgreSQL also accepts, is ignored without an error. A
SET issued after connecting works as usual.
Note also that DROP DATABASE on a name that does not exist returns a bare internal error
(XX000) rather than PostgreSQL’s 3D000, so it cannot be distinguished from an unrelated
failure — check pg_database first:
SELECT datname FROM pg_database WHERE datname = 'reports' ;
-- Enable / disable RLS on a table
ALTER TABLE t ENABLE ROW LEVEL SECURITY ;
ALTER TABLE t DISABLE ROW LEVEL SECURITY ;
-- Force RLS for table owner
ALTER TABLE t FORCE ROW LEVEL SECURITY ;
ALTER TABLE t NO FORCE ROW LEVEL SECURITY ;
-- Create, alter, and drop policies
CREATE POLICY name ON t FOR SELECT USING (expr);
CREATE POLICY name ON t FOR INSERT WITH CHECK (expr);
CREATE POLICY name ON t AS RESTRICTIVE FOR SELECT USING (expr);
ALTER POLICY name ON t USING (new_expr);
DROP POLICY [IF EXISTS] name ON t;
See Row-Level Security for complete policy semantics and examples.