Data Types & DDL
DDL — Data Definition Language — is the part of SQL that creates and changes schema. This is also where SQLite and PostgreSQL diverge the most, because they disagree fundamentally about what a “type” is.
CREATE TABLE
Section titled “CREATE TABLE”The basic form is identical in both engines:
CREATE TABLE authors ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, birth_year INTEGER, country TEXT DEFAULT 'unknown');Each column is name type [constraints...]. Constraints written on a column are column constraints; constraints written on their own line apply to the table and can span multiple columns.
CREATE TABLE order_items ( order_id INTEGER NOT NULL, book_id INTEGER NOT NULL, qty INTEGER NOT NULL, PRIMARY KEY (order_id, book_id) -- table constraint, composite key);Guard against re-running scripts:
CREATE TABLE IF NOT EXISTS authors ( ... );Types in SQLite: dynamic typing
Section titled “Types in SQLite: dynamic typing”SQLite does not enforce column types by default. A column’s declared type is a hint, and any value can go in any column. There are only five storage classes — the actual runtime types of values:
| Storage class | Holds |
|---|---|
NULL |
The null value |
INTEGER |
Signed integer, 1–8 bytes depending on magnitude |
REAL |
8-byte IEEE float |
TEXT |
String, in the database encoding (UTF-8 by default) |
BLOB |
Bytes, stored exactly as given |
Notice what’s missing: no boolean, no date, no time, no decimal, no UUID. Booleans are stored as 0/1 integers. Dates are stored as TEXT ('2026-08-09 12:00:00'), INTEGER (Unix seconds), or REAL (Julian day).
Type affinity
Section titled “Type affinity”Every column gets an affinity — a preferred storage class SQLite tries to convert to on insert. The affinity is derived from the declared type name by these rules, in order:
| Declared type contains | Affinity |
|---|---|
INT |
INTEGER |
CHAR, CLOB, or TEXT |
TEXT |
BLOB, or type omitted |
BLOB (i.e. none) |
REAL, FLOA, or DOUB |
REAL |
| anything else | NUMERIC |
So VARCHAR(255) → TEXT affinity, BIGINT → INTEGER, DOUBLE PRECISION → REAL, DECIMAL(10,2) and BOOLEAN and DATETIME → NUMERIC. This is why you can paste a PostgreSQL schema into SQLite and it “works”: unknown type names silently get NUMERIC affinity.
Affinity converts losslessly or not at all:
CREATE TABLE t (a INTEGER, b TEXT, c BLOB);INSERT INTO t VALUES ('42', 42, 42);SELECT typeof(a), typeof(b), typeof(c) FROM t;-- => integer|text|integer'42' went into an INTEGER-affinity column and became the integer 42. 42 went into a TEXT column and became '42'. The BLOB column has no affinity, so 42 stayed an integer.
INSERT INTO t (a) VALUES ('hello');SELECT typeof(a) FROM t WHERE a = 'hello';-- => text'hello' cannot become an integer, so it is stored as text — in a column declared INTEGER. No error.
STRICT tables (SQLite 3.37+)
Section titled “STRICT tables (SQLite 3.37+)”Since 3.37.0 you can opt into real type checking:
CREATE TABLE authors ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, birth_year INTEGER) STRICT;In a STRICT table every column must be declared as one of INT, INTEGER, REAL, TEXT, BLOB, or ANY, and inserting a value of the wrong type raises an error instead of coercing.
INSERT INTO authors (id, name) VALUES (1, 42);-- Error: cannot store INTEGER value in TEXT column authors.nameTypes in PostgreSQL: static and rich
Section titled “Types in PostgreSQL: static and rich”PostgreSQL types are enforced at insert time and drive operator and function resolution. The ones you actually need:
| Type | Notes |
|---|---|
smallint / integer / bigint |
2/4/8-byte signed integers. integer is the default choice. |
numeric(p, s) |
Exact decimal, arbitrary precision. Use this for money. |
real / double precision |
4/8-byte floats. Fast, inexact — never for money. |
text |
Unlimited-length string. |
varchar(n) / char(n) |
Length-limited. varchar(n) is text plus a length check. |
boolean |
True true/false/null. Accepts 't', 'yes', 1 on input. |
date, time, timestamp |
Calendar/clock values without a zone. |
timestamptz |
Instant in time. The one you almost always want. |
interval |
A duration: interval '3 days'. |
uuid |
16-byte UUID, not a string. |
json / jsonb |
JSON text vs parsed binary JSON. Use jsonb. |
bytea |
Binary blob. |
| arrays | Any type followed by []: text[], integer[]. |
| enums | User-defined via CREATE TYPE. |
CREATE TABLE books ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, author_id integer NOT NULL REFERENCES authors(id), title text NOT NULL, price numeric(6, 2) NOT NULL CHECK (price >= 0), tags text[] NOT NULL DEFAULT '{}', metadata jsonb NOT NULL DEFAULT '{}'::jsonb, in_print boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now());timestamptz vs timestamp
Section titled “timestamptz vs timestamp”timestamp stores wall-clock digits with no zone — 2026-08-09 14:00:00 is ambiguous, it could be any of 30 instants worldwide. timestamptz stores an absolute instant (internally UTC) and converts to the session’s TimeZone on output.
SET TimeZone = 'UTC';SELECT now(); -- 2026-08-09 12:00:00+00SET TimeZone = 'Europe/Paris';SELECT now(); -- 2026-08-09 14:00:00+02 (same instant)Auto-incrementing keys
Section titled “Auto-incrementing keys”Three spellings, one recommendation:
-- Legacy, still very common: creates a sequence behind the scenesid serial PRIMARY KEYid bigserial PRIMARY KEY
-- SQL-standard, PostgreSQL 10+, preferredid integer GENERATED ALWAYS AS IDENTITY PRIMARY KEYid bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEYGENERATED ALWAYS rejects a user-supplied value unless you write OVERRIDING SYSTEM VALUE. GENERATED BY DEFAULT lets you supply one — convenient for data loads, but then the sequence can fall behind and later inserts collide.
The SQLite equivalent is just INTEGER PRIMARY KEY, which aliases the hidden rowid and auto-assigns “one more than the current maximum”. Adding AUTOINCREMENT additionally guarantees IDs are never reused after deletion, at the cost of an extra sqlite_sequence table lookup per insert.
-- SQLiteCREATE TABLE books (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL);Enums and arrays (PostgreSQL only)
Section titled “Enums and arrays (PostgreSQL only)”CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled');
CREATE TABLE orders ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, status order_status NOT NULL DEFAULT 'pending');
INSERT INTO orders (status) VALUES ('shipped');INSERT INTO orders (status) VALUES ('lost');-- ERROR: invalid input value for enum order_status: "lost"Enum values sort in declaration order, which is often exactly what you want. Adding a value is easy (ALTER TYPE order_status ADD VALUE 'refunded';); removing or reordering one is not. If the value set changes often, a lookup table with a foreign key is the better design.
Arrays:
INSERT INTO books (title, tags) VALUES ('Kindred', ARRAY['sf', 'classic']);SELECT title FROM books WHERE 'sf' = ANY(tags);SELECT title, array_length(tags, 1) FROM books;SQLite has neither. The portable pattern is a join table, which is usually the better model anyway.
Constraints
Section titled “Constraints”Constraints are enforced by the database, so they hold no matter which application, script, or human writes the data. That is their entire point — application-level validation is bypassable, a CHECK constraint is not.
PRIMARY KEY
Section titled “PRIMARY KEY”Uniquely identifies a row. Implies UNIQUE, and in PostgreSQL implies NOT NULL.
PRIMARY KEY (order_id, book_id) -- compositeFOREIGN KEY and ON DELETE
Section titled “FOREIGN KEY and ON DELETE”A foreign key says “this value must exist in that table’s key column”.
CREATE TABLE books ( id INTEGER PRIMARY KEY, author_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE, title TEXT NOT NULL);The referential actions, both engines:
| Action | On delete of the parent row |
|---|---|
NO ACTION (default) |
Error if children exist |
RESTRICT |
Error, checked immediately (cannot be deferred) |
CASCADE |
Delete the children too |
SET NULL |
Null out the child’s FK column |
SET DEFAULT |
Set the child’s FK column to its default |
ON UPDATE takes the same actions and fires when the parent key value changes.
PostgreSQL enforces foreign keys always. It also supports deferring the check to commit time:
ALTER TABLE books ADD CONSTRAINT books_author_fk FOREIGN KEY (author_id) REFERENCES authors(id) DEFERRABLE INITIALLY DEFERRED;That lets you insert mutually-referencing rows inside one transaction. SQLite supports DEFERRABLE INITIALLY DEFERRED too, but only when foreign keys are enabled.
UNIQUE, NOT NULL, CHECK, DEFAULT
Section titled “UNIQUE, NOT NULL, CHECK, DEFAULT”CREATE TABLE customers ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, age INTEGER CHECK (age IS NULL OR age >= 13), credit NUMERIC(8,2) NOT NULL DEFAULT 0, UNIQUE (email) -- table-level form of the same thing);UNIQUEcreates a unique index. In both engines, multipleNULLs are allowed in a unique column —NULLis not equal to anything, including itself. This is standard behaviour and surprises nearly everyone.CHECKevaluates a boolean expression per row; the row is rejected if it evaluates to false. If it evaluates toNULL(unknown), the row is accepted — hence theage IS NULL OR ...above is redundant but explicit.DEFAULTsupplies a value when the column is omitted fromINSERT. It does not fire when you explicitly insertNULL.
CREATE TABLE events ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL CHECK (kind IN ('view', 'click', 'purchase')), amount NUMERIC(8,2) CHECK (amount > 0));CHECK (kind IN (...)) is the portable substitute for a PostgreSQL enum, and works in SQLite too.
ALTER TABLE
Section titled “ALTER TABLE”PostgreSQL’s ALTER TABLE is close to complete:
ALTER TABLE books ADD COLUMN isbn text;ALTER TABLE books DROP COLUMN isbn;ALTER TABLE books RENAME COLUMN title TO name;ALTER TABLE books RENAME TO publications;
ALTER TABLE books ALTER COLUMN price TYPE numeric(10,2);ALTER TABLE books ALTER COLUMN price SET NOT NULL;ALTER TABLE books ALTER COLUMN price SET DEFAULT 0;ALTER TABLE books ALTER COLUMN price DROP DEFAULT;
ALTER TABLE books ADD CONSTRAINT price_positive CHECK (price >= 0);ALTER TABLE books DROP CONSTRAINT price_positive;When a type change isn’t automatically castable, supply the conversion:
ALTER TABLE books ALTER COLUMN year TYPE integer USING year::integer;SQLite’s ALTER TABLE is deliberately minimal:
| Operation | SQLite support |
|---|---|
ADD COLUMN |
Yes, always |
RENAME TO |
Yes, always |
RENAME COLUMN |
3.25+ |
DROP COLUMN |
3.35+, with restrictions |
| Change a column’s type, default, or nullability | Not supported |
| Add or drop a constraint | Not supported |
ADD COLUMN in SQLite also refuses PRIMARY KEY, UNIQUE, a non-constant DEFAULT (like CURRENT_TIMESTAMP), and NOT NULL without a non-null default.
For anything else, use the 12-step rebuild — the official recipe, simplified:
PRAGMA foreign_keys = OFF;BEGIN;CREATE TABLE books_new ( id INTEGER PRIMARY KEY, author_id INTEGER NOT NULL REFERENCES authors(id), title TEXT NOT NULL, price NUMERIC NOT NULL DEFAULT 0 -- the change we wanted) STRICT;INSERT INTO books_new (id, author_id, title, price) SELECT id, author_id, title, COALESCE(price, 0) FROM books;DROP TABLE books;ALTER TABLE books_new RENAME TO books;-- recreate indexes, triggers and views hereCOMMIT;PRAGMA foreign_key_check;PRAGMA foreign_keys = ON;Do the whole thing inside a transaction so a failure leaves the old table intact.
DROP TABLE books;DROP TABLE IF EXISTS books;PostgreSQL additionally offers dependency handling and truncation:
DROP TABLE authors CASCADE; -- also drops FKs and views that depend on itTRUNCATE TABLE books; -- delete all rows, fast, reclaims spaceTRUNCATE books RESTART IDENTITY CASCADE;SQLite has neither CASCADE on DROP TABLE nor TRUNCATE. Use DELETE FROM books; — SQLite optimises a DELETE with no WHERE into a fast table truncation internally.
Inspecting a schema
Section titled “Inspecting a schema”# SQLitesqlite> .tablessqlite> .schema bookssqlite> PRAGMA table_info(books);# PostgreSQLbookstore=# \dtbookstore=# \d booksbookstore=# \d+ booksKey points
Section titled “Key points”- SQLite has five storage classes and dynamic typing; declared types only set an affinity. Use
STRICTtables to get real enforcement. - PostgreSQL has a rich, enforced type system — reach for
numericfor money,timestamptzfor instants,jsonbfor documents,textovervarchar(n). INTEGER PRIMARY KEYauto-numbers in SQLite; PostgreSQL needsGENERATED ALWAYS AS IDENTITY(or legacyserial).- SQLite ignores foreign keys unless
PRAGMA foreign_keys = ONon every connection. UNIQUEallows multipleNULLs in both engines; aCHECKthat evaluates toNULLpasses.- SQLite’s
ALTER TABLEonly adds/renames/drops columns — everything else needs a table rebuild. - DDL is transactional in both engines; wrap migrations in
BEGIN/COMMIT.