Referential integrity is one of the foundational ideas in relational database design, and foreign keys are how PostgreSQL enforces it. Their job is to stop the database from storing inconsistent data by constraining how rows in one table may refer to rows in another.

The trade-off is that keys introduce ordering requirements. Without any foreign keys, rows can be inserted into any table in any sequence — PostgreSQL places no restrictions. Once a key is in place, the sequence in which data arrives starts to matter.

A minimal model and the failure it produces

Consider a small schema holding currencies, products and product descriptions:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

CREATE TABLE t_currency

(

id int,

shortcut char (3),

PRIMARY KEY (id)

);

CREATE TABLE t_location

(

id int,

location_name text,

PRIMARY KEY (id)

);

CREATE TABLE t_product

(

id int,

name text,

currency_id int REFERENCES t_currency (id),

PRIMARY KEY (id)

);

CREATE TABLE t_product_desc

(

id int,

product_id int REFERENCES t_product (id),

description text,

PRIMARY KEY (id)

);

CREATE TABLE t_product_stock

(

product_id int REFERENCES t_product (id),

location_id int REFERENCES t_location (id),

amount numeric CHECK (amount >= 0)

);

Nothing unusual here. Inserting into the product table is where the problem appears:

1

2

3

4

5

6

test=# INSERT INTO t_product VALUES (1, 'PostgreSQL consulting', 1);

ERROR:  insert or update on table 't_product' violates foreign key constraint

't_product_currency_id_fkey'

DETAIL:  Key (currency_id)=(1) is not present in table 't_currency'.

test=# INSERT INTO t_product VALUES (1, 'PostgreSQL consulting', NULL);

INSERT 0 1

The first INSERT cannot succeed: currency 1 does not exist yet. The alternative is to write a NULL, meaning an unknown currency. In the default case, then, the currency table has to be populated before locations, and so on down the line. Order is not optional.

Deriving the order from the catalog

Working with a pre-existing model makes this harder to reason about, and loading an empty schema can become genuinely fiddly. Rather than trace the dependencies by hand, the insertion order can be produced by a query against the PostgreSQL system tables, which already hold everything needed to work it out:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

WITH RECURSIVE fkeys AS (

   /* source and target tables for all foreign keys */

   SELECT conrelid AS source,

          confrelid AS target

   FROM pg_constraint

   WHERE contype = 'f'

),

tables AS (

      (   /* all tables ... */

          SELECT oid AS table_name,

                 1 AS level,

                 ARRAY[oid] AS trail,

                 FALSE AS circular

          FROM pg_class

          WHERE relkind = 'r'

            AND NOT relnamespace::regnamespace::text LIKE ANY

                    (ARRAY['pg_catalog', 'information_schema', 'pg_temp_%'])

       EXCEPT

          /* ... except the ones that have a foreign key */

          SELECT source,

                 1,

                 ARRAY[ source ],

                 FALSE

          FROM fkeys

      )

   UNION ALL

      /* all tables with a foreign key pointing a table in the working set */

      SELECT fkeys.source,

             tables.level + 1,

             tables.trail || fkeys.source,

             tables.trail @> ARRAY[fkeys.source]

      FROM fkeys

         JOIN tables ON tables.table_name = fkeys.target

      /*

       * Stop when a table appears in the trail the third time.

       * This way, we get the table once with 'circular = TRUE'.

       */

      WHERE cardinality(array_positions(tables.trail, fkeys.source)) < 2

),

ordered_tables AS (

   /* get the highest level per table */

   SELECT DISTINCT ON (table_name)

          table_name,

          level,

          circular

   FROM tables

   ORDER BY table_name, level DESC

)

SELECT table_name::regclass,

       level

FROM ordered_tables

WHERE NOT circular

ORDER BY level, table_name;

The query is dense, but the output is what matters:

1

2

3

4

5

6

7

8

   table_name    | level

-----------------+-------

t_currency      |     1

t_location      |     1

t_product       |     2

t_product_desc  |     3

t_product_stock |     3

(5 rows)

The tables come back in the required sequence: everything at level one first, then the next level, and so on. Following that order keeps referential integrity intact, provided the data itself is correct.

Deferring the check to commit

Sometimes the ordering constraint is simply inconvenient. PostgreSQL offers a way out in the form of initially deferred constraints, which move the integrity check to commit time instead of enforcing it per statement:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

BEGIN;

CREATE TABLE t_currency

(

id            int,

shortcut      char (3),

PRIMARY KEY (id)

);

CREATE TABLE t_product

(

id            int,

name          text,

currency_id   int REFERENCES t_currency (id)

INITIALLY DEFERRED,

PRIMARY KEY (id)

);

INSERT INTO t_product VALUES (1, 'PostgreSQL support', 1);

INSERT INTO t_currency VALUES (1, 'EUR');

COMMIT;

With this in place, rows can be modified in any order. As long as the transaction leaves integrity intact when it ends, PostgreSQL accepts the work, postponing the check and removing some of the burden from the developer.