In a previous YugabyteDB Tip, we built a pure SQL function to export a tableβs CREATE TABLE DDL directly from YugabyteDB system catalogs:
That function was useful because it included several YugabyteDB-specific details that are easy to miss when manually reconstructing table DDL, including:
- β Inline primary keys
βSPLIT AT VALUES
βSPLIT INTO ... TABLETS
βTABLESPACE
βCOLOCATION
βCOLOCATION_ID
β Partitioned table parent definitions
β Column defaults
β Identity columns
But the original function focused mostly on the base table definition.
It did not attempt to export everything around the table, such as secondary indexes, non-primary-key constraints, triggers, or child partitions.
So in this tip, weβll expand the function.
The goal is not to replace ysql_dump.
The goal is to create a handy SQL-only helper that gives you a more complete table-focused DDL script from inside ysqlsh.
In YugabyteDB, the primary key is not just a logical constraint. It helps define how table data is distributed across tablets. That is why this function keeps the primary key inline inside the CREATE TABLE statement instead of adding it later with ALTER TABLE.
Why Not Just Use ysql_dump?
For complete schema export, backup, restore, or migration workflows, you should still use ysql_dump.
For example:
ysql_dump --include-yb-metadata --schema-only --table=public.orders yugabyte
That is the right tool when you need a complete and reliable schema dump.
But sometimes you want something lighter.
For example, you may want to inspect one table from inside ysqlsh, quickly document a table definition, compare DDL across environments, review tablet split clauses, or generate a table-focused script for a demo.
That is where a pure SQL helper function can be useful.
It lets you run:
SELECT public.export_table_plus('public.orders');
And get back a readable, mostly executable table-focused DDL script.
Why the Primary Key Must Stay Inline
In PostgreSQL, it is common for dump tools to create a table first and then add indexes and constraints later.
That pattern can be fine for PostgreSQL.
But YugabyteDB is different.
In YugabyteDB, the primary key affects the physical layout of the table. It influences how rows are distributed across tablets.
So this pattern is not ideal:
CREATE TABLE orders (
order_id bigint,
customer_id bigint,
order_date date
);
ALTER TABLE orders
ADD PRIMARY KEY (order_id);
In YugabyteDB, that can be expensive because the table may need to be rewritten and redistributed around the new primary key.
Instead, the primary key should be part of the original table definition:
CREATE TABLE orders (
order_id bigint,
customer_id bigint,
order_date date,
PRIMARY KEY (order_id HASH)
);
That is why this function keeps the primary key inline.
Secondary indexes, triggers, and other supporting objects can be emitted later, but the primary key belongs in the CREATE TABLE.
What This Expanded Function Includes
This version expands the original helper function to include more table-related objects.
| Object / Metadata | Included? | Notes |
| Columns | Yes | Includes data types, defaults, identity columns, and NOT NULL. |
| Primary key | Yes | Emitted inline inside CREATE TABLE. |
| Non-PK constraints | Yes | Includes unique, foreign key, check, and exclusion constraints. |
| Secondary indexes | Yes | Uses pg_get_indexdef(). Primary key index is excluded. |
| Triggers | Yes | User-defined triggers only. Internal triggers are excluded. |
| Child partitions | Yes | Direct child partitions are emitted. |
| Tablespaces | Yes | Included for the base table and child partitions. |
| YugabyteDB split metadata | Yes | Preserves SPLIT AT and SPLIT INTO behavior from the original function. |
How This Compares to \d, \dt, and \d+
ysqlsh already gives you helpful table inspection commands
For example:
\d public.orders
\d+ public.orders
\dt+ public.*
Those commands are great for humans.
They show table structure, indexes, constraints, triggers, ownership, access method, tablespace, and other metadata in a readable format.
But they do not generate a reusable CREATE TABLE script.
This function takes a different approach. It focuses on reconstructing executable table-focused DDL.
| Tool | Best For |
\d |
Quick table structure inspection. |
\d+ |
More detailed human-readable table metadata. |
\dt+ |
Listing tables with extra metadata. |
ysql_dump --include-yb-metadata |
Complete schema export, backup, restore, and migration workflows. |
export_table_plus() |
SQL-only, table-focused DDL reconstruction from inside ysqlsh. |
So no, this function does not include everything that \d+ shows.
It overlaps with \d+, but it is not equivalent to \d+.
For example, this function does not currently export grants, comments, row-level security policies, sequence definitions, trigger function bodies, publications, subscriptions, table sizes, or all recursive dependencies.
The Expanded Function
Here is the expanded function.
CREATE OR REPLACE FUNCTION public.export_table_plus(
p_rel regclass,
include_if_not_exists boolean DEFAULT false,
include_child_partitions boolean DEFAULT true,
include_indexes boolean DEFAULT true,
include_triggers boolean DEFAULT true
)
RETURNS text
LANGUAGE plpgsql
SET client_min_messages TO 'warning'
AS $function$
DECLARE
v_schema text;
v_table text;
v_relkind "char";
v_persistence "char";
v_reloptions text[];
v_cols text;
v_constraints text;
v_pk_clause text;
v_part_clause text;
v_tablespace text;
v_stmt text;
v_split_clause text;
v_with_items text[] := ARRAY[]::text[];
v_num_tablets int;
v_num_hash_cols int := 0;
v_is_colocated boolean;
v_colocation_id oid;
v_prefix text := 'CREATE TABLE';
v_conindid oid;
v_indexdef text;
v_allow_split_into boolean := false;
v_child_partitions text;
v_indexes text := '';
v_triggers text;
v_idx_rec record;
v_idx_num_tablets int;
v_idx_num_hash_cols int;
v_idx_is_colocated boolean;
v_idx_split_clause text;
v_idx_ddl text;
BEGIN
SELECT n.nspname,
c.relname,
c.relkind,
c.relpersistence,
c.reloptions
INTO v_schema, v_table, v_relkind, v_persistence, v_reloptions
FROM pg_class c
JOIN pg_namespace n
ON n.oid = c.relnamespace
WHERE c.oid = p_rel;
IF NOT FOUND THEN
RAISE EXCEPTION 'relation % not found', p_rel;
END IF;
IF v_persistence = 't' THEN
v_prefix := 'CREATE TEMP TABLE';
ELSIF v_persistence = 'u' THEN
v_prefix := 'CREATE UNLOGGED TABLE';
END IF;
IF include_if_not_exists THEN
v_prefix := v_prefix || ' IF NOT EXISTS';
END IF;
/*
* YugabyteDB table properties.
*
* These are used to help reconstruct SPLIT INTO, colocation metadata,
* and other YugabyteDB-specific physical layout details.
*/
BEGIN
SELECT num_tablets,
num_hash_key_columns,
is_colocated,
colocation_id
INTO v_num_tablets,
v_num_hash_cols,
v_is_colocated,
v_colocation_id
FROM yb_table_properties(p_rel);
EXCEPTION
WHEN OTHERS THEN
v_num_tablets := NULL;
v_num_hash_cols := 0;
v_is_colocated := NULL;
v_colocation_id := NULL;
END;
/*
* Column definitions.
*/
SELECT string_agg(def, E',\n ' ORDER BY attnum)
INTO v_cols
FROM (
SELECT
a.attnum,
format(
'%I %s%s%s%s',
a.attname,
format_type(a.atttypid, a.atttypmod),
CASE
WHEN ad.adbin IS NOT NULL
THEN ' DEFAULT ' || pg_get_expr(ad.adbin, ad.adrelid)
ELSE ''
END,
CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END,
CASE
WHEN a.attidentity = 'a' THEN ' GENERATED ALWAYS AS IDENTITY'
WHEN a.attidentity = 'd' THEN ' GENERATED BY DEFAULT AS IDENTITY'
ELSE ''
END
) AS def
FROM pg_attribute a
LEFT JOIN pg_attrdef ad
ON ad.adrelid = a.attrelid
AND ad.adnum = a.attnum
WHERE a.attrelid = p_rel
AND a.attnum > 0
AND NOT a.attisdropped
) s;
/*
* Reconstruct the primary key inline.
*
* This is important for YugabyteDB because the primary key affects
* how data is distributed across tablets.
*/
v_pk_clause := NULL;
SELECT conindid
INTO v_conindid
FROM pg_constraint
WHERE conrelid = p_rel
AND contype = 'p';
IF v_conindid IS NOT NULL THEN
SELECT pg_get_indexdef(v_conindid)
INTO v_indexdef;
v_pk_clause :=
regexp_replace(
v_indexdef,
'^.*USING\s+\S+\s+(\(.*\))$',
'PRIMARY KEY \1'
);
IF v_pk_clause = v_indexdef THEN
v_pk_clause := NULL;
END IF;
END IF;
/*
* Non-primary-key constraints.
*
* The primary key is intentionally excluded because it is emitted inline.
*/
SELECT string_agg(
'CONSTRAINT ' || quote_ident(conname) || ' ' || pg_get_constraintdef(oid, true),
E',\n '
ORDER BY contype, conname
)
INTO v_constraints
FROM pg_constraint
WHERE conrelid = p_rel
AND contype IN ('u','f','c','x');
IF v_pk_clause IS NOT NULL THEN
v_cols := v_cols || E',\n ' || v_pk_clause;
END IF;
IF v_constraints IS NOT NULL THEN
v_cols := v_cols || E',\n ' || v_constraints;
END IF;
/*
* PARTITION BY clause for partitioned table parents.
*/
IF v_relkind = 'p' THEN
SELECT 'PARTITION BY ' || pg_get_partkeydef(p_rel)
INTO v_part_clause;
END IF;
/*
* WITH (...) options.
*/
IF v_reloptions IS NOT NULL THEN
v_with_items := v_with_items || v_reloptions;
END IF;
IF v_is_colocated IS TRUE THEN
v_with_items := array_append(v_with_items, 'COLOCATION = true');
END IF;
IF v_colocation_id IS NOT NULL THEN
v_with_items := array_append(v_with_items, 'COLOCATION_ID = ' || v_colocation_id);
END IF;
/*
* Base CREATE TABLE statement.
*/
v_stmt :=
v_prefix || ' '
|| quote_ident(v_schema) || '.' || quote_ident(v_table)
|| ' (' || E'\n '
|| v_cols
|| E'\n)';
IF array_length(v_with_items, 1) IS NOT NULL THEN
v_stmt := v_stmt || E'\nWITH (' || array_to_string(v_with_items, ', ') || ')';
END IF;
IF v_part_clause IS NOT NULL THEN
v_stmt := v_stmt || E'\n' || v_part_clause;
END IF;
/*
* TABLESPACE.
*/
SELECT ts.spcname
INTO v_tablespace
FROM pg_class c
JOIN pg_tablespace ts
ON ts.oid = c.reltablespace
WHERE c.oid = p_rel
AND c.reltablespace <> 0;
IF v_tablespace IS NOT NULL THEN
v_stmt := v_stmt || E'\nTABLESPACE ' || quote_ident(v_tablespace);
END IF;
/*
* SPLIT clause for the base table.
*
* Range splits are reconstructed using yb_get_range_split_clause().
* Hash splits fall back to SPLIT INTO when appropriate.
*/
v_split_clause := NULL;
BEGIN
SELECT yb_get_range_split_clause(p_rel)
INTO v_split_clause;
EXCEPTION
WHEN OTHERS THEN
v_split_clause := NULL;
END;
IF v_split_clause IS NOT NULL THEN
v_split_clause := btrim(v_split_clause);
IF v_split_clause = '' THEN
v_split_clause := NULL;
END IF;
END IF;
v_allow_split_into :=
v_pk_clause IS NULL
OR position(' HASH' IN coalesce(v_pk_clause, '')) > 0
OR COALESCE(v_num_hash_cols, 0) > 0;
IF v_split_clause IS NOT NULL THEN
v_stmt := v_stmt || E'\n' || v_split_clause;
ELSIF v_allow_split_into
AND COALESCE(v_num_tablets, 0) > 1
AND COALESCE(v_is_colocated, false) = false
THEN
v_stmt := v_stmt || E'\nSPLIT INTO ' || v_num_tablets || ' TABLETS';
END IF;
v_stmt := v_stmt || ';';
/*
* Child partitions.
*
* This emits CREATE TABLE ... PARTITION OF statements for direct child
* partitions of the requested parent table.
*/
IF include_child_partitions THEN
SELECT string_agg(child_ddl, E'\n' ORDER BY child_name)
INTO v_child_partitions
FROM (
SELECT
child.relname AS child_name,
'CREATE TABLE '
|| quote_ident(child_ns.nspname) || '.' || quote_ident(child.relname)
|| ' PARTITION OF '
|| quote_ident(v_schema) || '.' || quote_ident(v_table)
|| ' '
|| pg_get_expr(child.relpartbound, child.oid)
|| CASE
WHEN child_ts.spcname IS NOT NULL
THEN E'\nTABLESPACE ' || quote_ident(child_ts.spcname)
ELSE ''
END
|| ';' AS child_ddl
FROM pg_inherits i
JOIN pg_class child
ON child.oid = i.inhrelid
JOIN pg_namespace child_ns
ON child_ns.oid = child.relnamespace
LEFT JOIN pg_tablespace child_ts
ON child_ts.oid = child.reltablespace
WHERE i.inhparent = p_rel
) s;
IF v_child_partitions IS NOT NULL THEN
v_stmt := v_stmt
|| E'\n\n-- Child Partitions\n'
|| v_child_partitions;
END IF;
END IF;
/*
* Secondary indexes.
*
* Primary key indexes are intentionally excluded because the primary key
* is emitted inline inside CREATE TABLE.
*
* pg_get_indexdef() does not include YugabyteDB split metadata, so this
* block separately checks each index with yb_table_properties() and
* appends SPLIT AT / SPLIT INTO metadata when available.
*/
IF include_indexes THEN
FOR v_idx_rec IN
SELECT i.indexrelid,
ic.relname AS index_name,
pg_get_indexdef(i.indexrelid) AS indexdef
FROM pg_index i
JOIN pg_class ic
ON ic.oid = i.indexrelid
WHERE i.indrelid = p_rel
AND i.indisprimary = false
ORDER BY ic.relname
LOOP
v_idx_num_tablets := NULL;
v_idx_num_hash_cols := 0;
v_idx_is_colocated := NULL;
v_idx_split_clause := NULL;
BEGIN
SELECT num_tablets,
num_hash_key_columns,
is_colocated
INTO v_idx_num_tablets,
v_idx_num_hash_cols,
v_idx_is_colocated
FROM yb_table_properties(v_idx_rec.indexrelid);
EXCEPTION
WHEN OTHERS THEN
v_idx_num_tablets := NULL;
v_idx_num_hash_cols := 0;
v_idx_is_colocated := NULL;
END;
BEGIN
SELECT yb_get_range_split_clause(v_idx_rec.indexrelid)
INTO v_idx_split_clause;
EXCEPTION
WHEN OTHERS THEN
v_idx_split_clause := NULL;
END;
IF v_idx_split_clause IS NOT NULL THEN
v_idx_split_clause := btrim(v_idx_split_clause);
IF v_idx_split_clause = '' THEN
v_idx_split_clause := NULL;
END IF;
END IF;
v_idx_ddl := v_idx_rec.indexdef;
IF v_idx_split_clause IS NOT NULL THEN
v_idx_ddl := v_idx_ddl || E'\n' || v_idx_split_clause;
ELSIF COALESCE(v_idx_is_colocated, false) = false
AND COALESCE(v_idx_num_tablets, 0) > 1
AND (
COALESCE(v_idx_num_hash_cols, 0) > 0
OR position(' HASH' IN v_idx_rec.indexdef) > 0
)
THEN
v_idx_ddl := v_idx_ddl || E'\nSPLIT INTO ' || v_idx_num_tablets || ' TABLETS';
END IF;
v_idx_ddl := v_idx_ddl || ';';
IF v_indexes = '' THEN
v_indexes := v_idx_ddl;
ELSE
v_indexes := v_indexes || E'\n' || v_idx_ddl;
END IF;
END LOOP;
IF v_indexes <> '' THEN
v_stmt := v_stmt
|| E'\n\n-- Indexes\n'
|| v_indexes;
END IF;
END IF;
/*
* User-defined triggers.
*
* Internal/system triggers are excluded.
*/
IF include_triggers THEN
SELECT string_agg(pg_get_triggerdef(t.oid) || ';', E'\n' ORDER BY t.tgname)
INTO v_triggers
FROM pg_trigger t
WHERE t.tgrelid = p_rel
AND t.tgisinternal = false;
IF v_triggers IS NOT NULL THEN
v_stmt := v_stmt
|| E'\n\n-- Triggers\n'
|| v_triggers;
END IF;
END IF;
RETURN v_stmt;
END;
$function$;
Demo: Comparing \d with export_table_plus()
To demonstrate the function, letβs use a table named public.orders.
This table includes several objects that are useful for testing DDL reconstruction:
- β An inline primary key
β A default value
β Check constraints
β A foreign key constraint
β Secondary indexes
β A trigger
β Different tablet split counts for the table and indexes
First, letβs inspect the table using the standard ysqlsh \d command:
yugabyte=# \d public.orders
Output:
. Table "public.orders"
Column | Type | Collation | Nullable | Default
--------------+--------------------------+-----------+----------+-------------
order_id | bigint | | not null |
customer_id | bigint | | not null |
order_status | text | | not null | 'NEW'::text
order_total | numeric(12,2) | | not null |
order_region | text | | not null |
created_at | timestamp with time zone | | | now()
updated_at | timestamp with time zone | | | now()
Indexes:
"orders_pkey" PRIMARY KEY, lsm (order_id HASH)
"orders_customer_id_idx" lsm (customer_id HASH, created_at DESC)
"orders_region_status_idx" lsm (order_region HASH, order_status ASC)
Check constraints:
"orders_status_check" CHECK (order_status = ANY (ARRAY['NEW'::text, 'PAID'::text, 'SHIPPED'::text, 'CANCELLED'::text]))
"orders_total_check" CHECK (order_total >= 0::numeric)
Foreign-key constraints:
"orders_customer_fk" FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
Triggers:
orders_audit_trigger BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION orders_audit_trigger_fn()
This output is great for interactive inspection.
It shows the columns, indexes, constraints, foreign key, and trigger in a readable format.
But it does not give us a reusable CREATE TABLE script. It also does not show the YugabyteDB tablet split details for the table or indexes.
That is where export_table_plus() helps.
Before running the function, I like to switch ysqlsh into unaligned, tuples-only output mode. This makes the generated DDL easier to copy and paste.
yugabyte=# \a
Output format is unaligned.
yugabyte=# \t
Tuples only is on.
Now run the export function.
yugabyte=# SELECT public.export_table_plus('public.orders');
Output:
CREATE TABLE public.orders (
order_id bigint NOT NULL,
customer_id bigint NOT NULL,
order_status text DEFAULT 'NEW'::text NOT NULL,
order_total numeric(12,2) NOT NULL,
order_region text NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
PRIMARY KEY (order_id HASH),
CONSTRAINT orders_status_check CHECK (order_status = ANY (ARRAY['NEW'::text, 'PAID'::text, 'SHIPPED'::text, 'CANCELLED'::text])),
CONSTRAINT orders_total_check CHECK (order_total >= 0::numeric),
CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
)
SPLIT INTO 8 TABLETS;
-- Indexes
CREATE INDEX orders_customer_id_idx ON public.orders USING lsm (customer_id HASH, created_at DESC)
SPLIT INTO 6 TABLETS;
CREATE INDEX orders_region_status_idx ON public.orders USING lsm (order_region HASH, order_status ASC)
SPLIT INTO 3 TABLETS;
-- Triggers
CREATE TRIGGER orders_audit_trigger BEFORE UPDATE ON public.orders FOR EACH ROW EXECUTE FUNCTION orders_audit_trigger_fn();
Now we have something much closer to executable DDL.
The function reconstructs the base table definition, keeps the primary key inline, includes the check and foreign key constraints, and appends the secondary indexes and trigger.
The important YugabyteDB-specific detail is that the generated DDL also includes tablet split metadata.
In this example:
- β The base table is split into
8tablets
βorders_customer_id_idxis split into6tablets
βorders_region_status_idxis split into3tablets
That matters because YugabyteDB secondary indexes are distributed objects too. They can have their own physical tablet layout, separate from the base table.
Notice that the base table is split into 8 tablets, while the secondary indexes use their own split counts. In YugabyteDB, indexes are distributed objects too, so their physical split metadata must be exported separately from the table definition.
There is one more important caveat.
The function exports the trigger definition, but it does not export the trigger function body.
In the output above, the trigger references this function:
orders_audit_trigger_fn()
That function must already exist before the trigger can be recreated successfully.
The export function includes the trigger definition, but it does not export the trigger function body. If you are recreating this table elsewhere, make sure the referenced trigger function exists before creating the trigger.
This is why export_table_plus() is useful for quick inspection, documentation, and table-focused DDL reconstruction, but ysql_dump --include-yb-metadata is still the better choice for complete schema export.
Important Caveats
This function is useful, but it is not a complete schema export tool.
It is best thought of as a table-focused DDL helper for quick inspection, troubleshooting, demos, and documentation.
It does not currently export:
- β Grants
β Comments
β Row-level security policies
β Sequence definitions
β Sequence ownership
β Trigger function bodies
β Views
β Materialized views
β Foreign tables
β Rules
β Publications
β Subscriptions
β Table size metadata
β Full recursive partition hierarchies
β Every dependent object outside the requested table
For example, the demo output includes this trigger:
CREATE TRIGGER orders_audit_trigger
BEFORE UPDATE ON public.orders
FOR EACH ROW
EXECUTE FUNCTION orders_audit_trigger_fn();
That is helpful, but it does not include the definition of orders_audit_trigger_fn().
So if you were recreating the table elsewhere, the trigger function would need to exist before the trigger could be created successfully.
The same idea applies to other external dependencies. If a column default references a sequence, the default expression may appear in the exported table DDL, but the sequence definition and ownership are not exported by this function.
Use this function for quick table-level DDL reconstruction. For full schema export, backup, restore, or migration workflows, continue to use ysql_dump --include-yb-metadata.
Why This Matters
YugabyteDB is PostgreSQL-compatible, but table layout has distributed systems implications.
A CREATE TABLE statement in YugabyteDB does more than define columns.
It can also define how data is distributed, how many tablets are created, whether the table is colocated, and where the table should be placed when tablespaces are used.
The primary key is especially important because it affects the tableβs physical distribution. That is why this function keeps the primary key inline inside the CREATE TABLE statement instead of adding it later with ALTER TABLE.
Indexes matter too.
In YugabyteDB, secondary indexes are distributed objects. They can have their own tablet layout, separate from the base table.
That is why the demo output showed:
CREATE TABLE public.orders (...)
SPLIT INTO 8 TABLETS;
But the indexes had their own split counts:
CREATE INDEX orders_customer_id_idx ...
SPLIT INTO 6 TABLETS;
CREATE INDEX orders_region_status_idx ...
SPLIT INTO 3 TABLETS;
A table inspection command like \d public.orders is great for seeing the logical structure of the table. But it does not show every YugabyteDB physical layout detail needed to recreate the table and index definitions.
That is the gap this function helps fill.
In YugabyteDB, the base table and its secondary indexes can have different physical tablet layouts. A useful DDL export helper needs to capture split metadata for both.
Final Takeaway
export_table_plus() is not a replacement for \d, \d+, or ysql_dump.
Those tools still have their place.
Use \d and \d+ when you want quick, human-readable inspection.
Use ysql_dump --include-yb-metadata when you need a complete and reliable schema export.
Use export_table_plus() when you want a lightweight, SQL-only way to reconstruct table-focused DDL directly from ysqlsh.
The most important YugabyteDB-specific detail is that the primary key stays inline, and the split metadata is captured for both the base table and its secondary indexes.
A pure SQL export helper is perfect for quick table-level DDL reconstruction, but it should remain YugabyteDB-aware. Keep the primary key inline, export table split metadata, and remember that secondary indexes can have their own split configuration too.
Have Fun!
