Export YugabyteDB CREATE TABLE DDL Using Pure SQL (Including SPLIT AT and SPLIT INTO)

Intro

To export schema from YugabyteDB, the most common approach is to use the ysql_dump utility:

				
					ysql_dump --include-yb-metadata
				
			

This produces DDL that includes YugabyteDB-specific clauses such as:

  • SPLIT INTO ... TABLETS

  • SPLIT AT VALUES

  • TABLESPACE

  • COLOCATION

However, sometimes you want something simpler:

  • ● inspect a table from inside ysqlsh

  • ● generate documentation

  • ● quickly recreate a table

  • ● validate tablet splits

  • ● export a single table without running a full dump

With a little catalog introspection, you can reconstruct a clean CREATE TABLE statement directly from SQL, including YugabyteDB physical layout metadata.

🔄 Enhanced Version of an Earlier Tip

In an earlier YugabyteDB Tip we showed how to generate a table’s CREATE TABLE statement directly from system catalogs:

👉 Export Table DDL Statement

This article expands that idea by reconstructing a more complete YugabyteDB-aware CREATE TABLE statement, including features such as tablet split clauses, colocation metadata, tablespaces, and inline primary keys using catalog functions like yb_table_properties() and yb_get_range_split_clause().

In the original tip, the goal was to export the basic table definition. In this updated version, we go further and reconstruct the full YugabyteDB physical table layout, including split clauses and colocation settings.

💡 Key Insight

YugabyteDB stores table metadata across several system catalogs.

By combining metadata from:

  • pg_class
  • pg_attribute
  • pg_constraint
  • yb_table_properties()
  • yb_get_range_split_clause()

we can reconstruct a YugabyteDB-aware CREATE TABLE statement entirely from SQL.

The Challenge

Two different tablet strategies exist in YugabyteDB.

Range partitioned tables

These use explicit split points.

Example:

				
					SPLIT AT VALUES ((100), (200), (200,5))
				
			

These can be reconstructed using:

				
					yb_get_range_split_clause()
				
			
Hash partitioned tables

These use a fixed tablet count.

Example:

				
					SPLIT INTO 5 TABLETS
				
			

These can be reconstructed using:

				
					yb_table_properties()
				
			
⚙️ Why Not Just Use ysql_dump?

ysql_dump --include-yb-metadata is still the official YugabyteDB export method.

However, SQL-based export has several advantages:

  • 🔎 Quick schema introspection
  • ⚙️ Easier to script
  • 📖 Easier to read
  • 📝 Great for documentation
  • 🎓 Perfect for demos and workshops

The SQL Function

The function below reconstructs a table definition including:

  • ● inline primary key

  • ● defaults

  • ● identity columns

  • ● tablespaces

  • ● partitioning

  • ● colocated tables

  • COLOCATION_ID

  • ● hash splits

  • ● range splits

  • TEMP / UNLOGGED

				
					CREATE OR REPLACE FUNCTION public.export_table(
    p_rel regclass,
    include_if_not_exists boolean DEFAULT false
)
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;
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;

  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 PK from pg_get_indexdef()
   */
  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-PK constraints
   */
  SELECT string_agg(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
   */
  IF v_relkind = 'p' THEN
    SELECT 'PARTITION BY ' || pg_get_partkeydef(p_rel)
    INTO v_part_clause;
  END IF;

  /*
   * WITH (...)
   */
  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
   */
  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
   *
   * Use the exact same call pattern that test_split_clause() proved works.
   * Then only allow SPLIT INTO fallback for clearly hash-style / non-range cases.
   */
  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;

  /*
   * Conservative fallback:
   * - allow SPLIT INTO when there is no PK clause at all
   * - or when the PK clause explicitly contains HASH
   * - or when YB says there are hash key columns
   */
  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;

  RETURN v_stmt || ';';
END;
$function$;
				
			

Demo

YugabyteDB tables are physically split into tablets (shards) across the cluster. These splits can be defined explicitly with SPLIT AT VALUES for range sharding or SPLIT INTO N TABLETS for hash sharding.

Example 1: Range Partitioned Table

Create table:

				
					CREATE TABLE tbl(
  a int,
  b int,
  primary key(a asc, b desc)
)
SPLIT AT VALUES((100),(200),(200,5));
				
			

Run export:

				
					SELECT export_table('tbl');
				
			

Result:

				
					CREATE TABLE public.tbl (
  a integer,
  b integer,
  PRIMARY KEY (a ASC, b DESC)
)
SPLIT AT VALUES ((100, MINVALUE), (200, MINVALUE), (200, 5));
				
			
Example 2: Hash Partitioned Table

Create table:

				
					CREATE TABLE test(
  c1 INT,
  c2 INT,
  c3 DATE,
  c4 INT UNIQUE,
  PRIMARY KEY((c1, c2), c3)
)
SPLIT INTO 10 TABLETS;
				
			

Result:

				
					CREATE TABLE public.test (
  c1 integer NOT NULL,
  c2 integer NOT NULL,
  c3 date NOT NULL,
  c4 integer,
  PRIMARY KEY ((c1, c2) HASH, c3 ASC),
  UNIQUE (c4)
)
SPLIT INTO 10 TABLETS;
				
			

Caveats

There are a few important things to understand.

1️⃣ Current tablet count vs original splits

For hash-partitioned tables, the function reads:

				
					yb_table_properties().num_tablets
				
			

If tablet splitting has occurred, this reflects the current physical layout, not necessarily the original DDL.

2️⃣ This is not a full pg_dump replacement

The function does not export:

  • ● indexes

  • ● grants

  • ● comments

  • ● triggers

  • ● policies

  • ● sequences

  • ● foreign tables

It focuses strictly on table creation DDL.

3️⃣ Partition child tables

The function exports partition parents.

Child partitions (CREATE TABLE ... PARTITION OF) require additional catalog logic.

Conclusion

While ysql_dump --include-yb-metadata remains the official export tool, it is not always the most convenient option when you just want to inspect or reconstruct table definitions.

By combining system catalogs with:

				
					yb_get_range_split_clause()
yb_table_properties()
				
			

you can generate a clean YugabyteDB-aware CREATE TABLE statement entirely from SQL.

This makes it easy to:

  • ● document schemas

  • ● validate tablet splits

  • ● reproduce tables in demos

  • ● inspect YugabyteDB metadata quickly

And best of all, it works directly inside ysqlsh.

Have Fun!