[PATCH] Add pg_get_table_ddl() to reconstruct CREATE TABLE statements
Hackers,
Attached is a patch adding pg_get_table_ddl(regclass, VARIADIC text[]),
extending the existing pg_get_database/role/tablespace_ddl family.
relations. It returns the CREATE TABLE statement plus the follow-up ALTER
TABLE, CREATE INDEX, CREATE RULE, and CREATE STATISTICS statements needed
for a full reconstruction, one per row.
*Coverage*
--------
*Per-column:* typmod, COLLATE, STORAGE, COMPRESSION (pglz/lz4), GENERATED
STORED/VIRTUAL, IDENTITY ALWAYS/BY DEFAULT (with non-default sequence
options), DEFAULT, NOT NULL, attoptions.
*Table-level:* UNLOGGED, INHERITS, PARTITION BY (RANGE/LIST/HASH),
PARTITION OF ... FOR VALUES, USING access method, WITH (reloptions),
TABLESPACE, inline CHECK.
*Sub-objects:* Indexes, Constraints (PK/UNIQUE/FK/EXCLUDE/named NOT NULL),
Rules, extended statistics, REPLICA IDENTITY, ENABLE/FORCE RLS, and
child-local DEFAULT overrides.
*Triggers and policies are TODOs pending pg_get_trigger_ddl (Phil's
re-roll) and pg_get_policy_ddl (Waiting for review/commit)*.
Options (pretty, owner, tablespace, and a family of *includes_** toggles
for each sub-object class) let callers fine-tune the output. Every clause
is omitted when its value equals what the system would reapply on
round-trip same default-omission convention as the existing _ddl functions.
I deliberately did *not* add pg_get_index_ddl / _constraint_ddl / _rule_ddl
/ _stat_ddl wrappers. The existing C helpers in ruleutils.c
(pg_get_indexdef_string, pg_get_constraintdef_command, etc.) already emit
reproducible statements, so pg_get_table_ddl_internal calls them directly.
Happy to revisit if reviewers prefer separate SQL-level surfaces.
*Testing: *
pg_regress test at src/test/regress/sql/pg_get_table_ddl.sql covering the
matrix above plus error paths. I kept it as pg_regress rather than TAP
(where the sibling _ddl tests live) since exact-.out diffs are the more
rigorous check for a deparse function, and the pg_regress restriction that
drove the TAP choice for CREATE DATABASE/TABLESPACE doesn't apply to
tables. A manual round-trip across IDENTITY/GENERATED/
STORAGE/COMPRESSION/PK produces an empty EXCEPT diff against the original.
*Out of scope:* COMMENT ON, GRANT/REVOKE (matching the existing _ddl
family), and typed tables (CREATE TABLE name OF type).
Feedback is welcome, particularly on the option naming and the decision to
reuse the C helpers directly rather than add new SQL wrappers.
-----
Regards,
Akshay Joshi
Principal Engineer | Engineering Manager | pgAdmin Hacker
EDB (EnterpriseDB)
Attachments:
v1-0001-Add-pg_get_table_ddl-to-reconstruct-CREATE-TABLE.patchapplication/octet-stream; name=v1-0001-Add-pg_get_table_ddl-to-reconstruct-CREATE-TABLE.patchDownload+2198-2
Hi Hackers,
Attached is the v2 patch, which fixes the Meson build failure, and the
rebased patch.
On Wed, Jun 3, 2026 at 6:28 PM Akshay Joshi <akshay.joshi@enterprisedb.com>
wrote:
Show quoted text
Hackers,
Attached is a patch adding pg_get_table_ddl(regclass, VARIADIC text[]),
extending the existing pg_get_database/role/tablespace_ddl family.
relations. It returns the CREATE TABLE statement plus the follow-up ALTER
TABLE, CREATE INDEX, CREATE RULE, and CREATE STATISTICS statements needed
for a full reconstruction, one per row.*Coverage*
--------
*Per-column:* typmod, COLLATE, STORAGE, COMPRESSION (pglz/lz4), GENERATED
STORED/VIRTUAL, IDENTITY ALWAYS/BY DEFAULT (with non-default sequence
options), DEFAULT, NOT NULL, attoptions.*Table-level:* UNLOGGED, INHERITS, PARTITION BY (RANGE/LIST/HASH),
PARTITION OF ... FOR VALUES, USING access method, WITH (reloptions),
TABLESPACE, inline CHECK.*Sub-objects:* Indexes, Constraints (PK/UNIQUE/FK/EXCLUDE/named NOT
NULL), Rules, extended statistics, REPLICA IDENTITY, ENABLE/FORCE RLS, and
child-local DEFAULT overrides.
*Triggers and policies are TODOs pending pg_get_trigger_ddl (Phil's
re-roll) and pg_get_policy_ddl (Waiting for review/commit)*.Options (pretty, owner, tablespace, and a family of *includes_** toggles
for each sub-object class) let callers fine-tune the output. Every clause
is omitted when its value equals what the system would reapply on
round-trip same default-omission convention as the existing _ddl functions.I deliberately did *not* add pg_get_index_ddl / _constraint_ddl /
_rule_ddl / _stat_ddl wrappers. The existing C helpers in ruleutils.c
(pg_get_indexdef_string, pg_get_constraintdef_command, etc.) already emit
reproducible statements, so pg_get_table_ddl_internal calls them directly.
Happy to revisit if reviewers prefer separate SQL-level surfaces.*Testing: *
pg_regress test at src/test/regress/sql/pg_get_table_ddl.sql covering the
matrix above plus error paths. I kept it as pg_regress rather than TAP
(where the sibling _ddl tests live) since exact-.out diffs are the more
rigorous check for a deparse function, and the pg_regress restriction that
drove the TAP choice for CREATE DATABASE/TABLESPACE doesn't apply to
tables. A manual round-trip across IDENTITY/GENERATED/
STORAGE/COMPRESSION/PK produces an empty EXCEPT diff against the original.*Out of scope:* COMMENT ON, GRANT/REVOKE (matching the existing _ddl
family), and typed tables (CREATE TABLE name OF type).Feedback is welcome, particularly on the option naming and the decision to
reuse the C helpers directly rather than add new SQL wrappers.-----
Regards,
Akshay Joshi
Principal Engineer | Engineering Manager | pgAdmin Hacker
EDB (EnterpriseDB)
Attachments:
v2-0001-Add-pg_get_table_ddl-to-reconstruct-CREATE-TABLE.patchapplication/octet-stream; name=v2-0001-Add-pg_get_table_ddl-to-reconstruct-CREATE-TABLE.patchDownload+2198-2
Em seg., 8 de jun. de 2026 às 08:30, Akshay Joshi <
akshay.joshi@enterprisedb.com> escreveu:
Hi Hackers,
Attached is the v2 patch, which fixes the Meson build failure, and the
rebased patch.
Would be good to have an option "schema-qualified" boolean, because
sometimes I don't want a schema-qualified result.
Suppose you are duplicating a schema, the way your did you cannot do
something like this to have a complete script to generate a new schema
select 'create schema new_schema;' union all
select 'set search_path to new_schema, public;' union all
select string_agg(pg_get_table_ddl(oid),',') from pg_class where
relnamespace::regnamespace::text = 'old_schema' and relkind = 'r';
regards
Marcos
Hello!
I did some basic testing with the new functions, and found a few bugs:
1. Seems like check constraints on partitions are ignored:
CREATE TABLE p (id int, val int) PARTITION BY RANGE (id);
CREATE TABLE p_child PARTITION OF p (CONSTRAINT chk_inline CHECK (val > 0))
FOR VALUES FROM (0) TO (100);
SELECT * FROM pg_get_table_ddl('p_child', 'owner','false');
2. inherited stored generated columns can't be replayed:
CREATE TABLE par_s (
id int,
g int GENERATED ALWAYS AS (id * 2) STORED
);
CREATE TABLE ch_s () INHERITS (par_s);
SELECT * FROM pg_get_table_ddl('ch_s', 'owner','false');
-- CREATE TABLE public.ch_s () INHERITS (public.par_s);
-- ALTER TABLE public.ch_s ALTER COLUMN g SET DEFAULT (id * 2);
Dropping ch_s, executing the returned statements:
ERROR: column "g" of relation "ch_s" is a generated column
HINT: Use ALTER TABLE ... ALTER COLUMN ... SET EXPRESSION instead.
3. named not null constraints can't be replayed:
CREATE TABLE t (a int CONSTRAINT my_nn NOT NULL);
SELECT * FROM pg_get_table_ddl('t'::regclass,'owner','false');
-- CREATE TABLE public.t ( a integer NOT NULL);
-- ALTER TABLE public.t ADD CONSTRAINT my_nn NOT NULL a;
Dropping t, executing the statements:
ERROR: cannot create not-null constraint "my_nn" on column "a" of table "t"
DETAIL: A not-null constraint named "t_a_not_null" already exists for this column.
Thanks to Zsolt and Marcos for the review.
Added *schema_qualified (boolean, default true)*. When false, the target
table is emitted unqualified everywhere (e.g., CREATE TABLE, ALTER TABLE,
INHERITS, PARTITION OF, identity SEQUENCE NAME, etc.); same-schema sibling
references follow suit; cross-schema references (e.g. FK targets in a
different schema) remain qualified for correctness. Output from the
always-qualified ruleutils helpers (pg_get_indexdef_string,
pg_get_constraintdef_command, pg_get_ruledef,
pg_get_statisticsobjdef_string) is post-processed to strip the base-schema
prefix.
1) CHECK constraints on partition children are now emitted as ALTER TABLE …
ADD CONSTRAINT … CHECK (…). They had been silently dropped because the
PARTITION OF form has no column list to inline them into.
2) Inherited generated columns no longer emit a spurious ALTER COLUMN … SET
DEFAULT, which would fail at replay.
3) User-named NOT NULL constraints are now emitted inline as CONSTRAINT
<name> NOT NULL. Auto-named NOT NULLs keep the existing inline-NOT NULL +
ALTER TABLE dedup behaviour so the common-case output is unchanged.
4) Added regression coverage for the three bug fixes plus the
schema_qualified=false paths (same-schema vs cross-schema,
INHERITS/PARTITION OF parents, custom identity sequence name, replay into a
different target schema)
Attached is the v3 patch, ready for review.
On Tue, Jun 9, 2026 at 2:42 AM Zsolt Parragi <zsolt.parragi@percona.com>
wrote:
Show quoted text
Hello!
I did some basic testing with the new functions, and found a few bugs:
1. Seems like check constraints on partitions are ignored:
CREATE TABLE p (id int, val int) PARTITION BY RANGE (id);
CREATE TABLE p_child PARTITION OF p (CONSTRAINT chk_inline CHECK (val > 0))
FOR VALUES FROM (0) TO (100);
SELECT * FROM pg_get_table_ddl('p_child', 'owner','false');2. inherited stored generated columns can't be replayed:
CREATE TABLE par_s (
id int,
g int GENERATED ALWAYS AS (id * 2) STORED
);
CREATE TABLE ch_s () INHERITS (par_s);
SELECT * FROM pg_get_table_ddl('ch_s', 'owner','false');
-- CREATE TABLE public.ch_s () INHERITS (public.par_s);
-- ALTER TABLE public.ch_s ALTER COLUMN g SET DEFAULT (id * 2);Dropping ch_s, executing the returned statements:
ERROR: column "g" of relation "ch_s" is a generated column
HINT: Use ALTER TABLE ... ALTER COLUMN ... SET EXPRESSION instead.3. named not null constraints can't be replayed:
CREATE TABLE t (a int CONSTRAINT my_nn NOT NULL);
SELECT * FROM pg_get_table_ddl('t'::regclass,'owner','false');
-- CREATE TABLE public.t ( a integer NOT NULL);
-- ALTER TABLE public.t ADD CONSTRAINT my_nn NOT NULL a;Dropping t, executing the statements:
ERROR: cannot create not-null constraint "my_nn" on column "a" of table
"t"
DETAIL: A not-null constraint named "t_a_not_null" already exists for
this column.
Attachments:
v3-0001-Add-pg_get_table_ddl-to-reconstruct-CREATE-TABLE.patchapplication/octet-stream; name=v3-0001-Add-pg_get_table_ddl-to-reconstruct-CREATE-TABLE.patchDownload+2865-2
Thanks, I can confirm that the previous bugs were fixed, however the bugfixes also introduce a new issue, where inherited not null constraints are missing:
CREATE TABLE par (a int);
CREATE TABLE ch (b int) INHERITS (par);
ALTER TABLE ch ADD CONSTRAINT my_nn NOT NULL a;
SELECT * FROM pg_get_table_ddl('ch','owner','false');
-- CREATE TABLE public.ch ( b integer) INHERITS (public.par);
The schema_qualified => false part also doesn't work as described:
+ <command>CREATE STATISTICS</command> statement. References to
+ objects in the same schema as the target table (inheritance
+ parents, partition parents, identity sequences, and any
+ same-schema object the deparse helpers happen to mention) are
+ also emitted unqualified, so the script can be replayed under a
+ different <varname>search_path</varname> to recreate the table
+ in another schema.
but:
CREATE SCHEMA s1;
CREATE SEQUENCE s1.myseq;
CREATE FUNCTION s1.f(int) RETURNS int LANGUAGE sql IMMUTABLE AS 'SELECT $1';
CREATE TABLE s1.t (
id int DEFAULT nextval('s1.myseq'),
val int,
CONSTRAINT chk CHECK (s1.f(val) > 0)
);
SELECT * FROM pg_get_table_ddl('s1.t', 'owner','false', 'schema_qualified','false');
-- CREATE TABLE t ( id integer DEFAULT nextval('s1.myseq'::regclass), val integer, CONSTRAINT chk CHECK ((s1.f(val) > 0)));
s1 appears twice in the output.
It also has an issue with strings containing the schema:
CREATE SCHEMA myschema;
CREATE TABLE myschema.p (id int, note text) PARTITION BY RANGE (id);
CREATE TABLE myschema.pc PARTITION OF myschema.p
(CONSTRAINT chk CHECK (note <> 'myschema.secret')) FOR VALUES FROM (0) TO (100);
SELECT * FROM pg_get_table_ddl('myschema.pc', 'owner','false', 'schema_qualified','false');
-- CREATE TABLE pc PARTITION OF p FOR VALUES FROM (0) TO (100);
-- ALTER TABLE pc ADD CONSTRAINT chk CHECK ((note <> 'secret'::text));