SQL CREATE TABLE
CREATE TABLE is how you define a new table — its name, the columns it contains, the data type of each column, and any rules (constraints) that data must follow. Getting table design right upfront saves significant pain later when you’re trying to migrate schemas on live data.
Basic Syntax
CREATE TABLE table_name ( column1 data_type [constraints], column2 data_type [constraints], ... [table_constraints]);A minimal but complete example:
CREATE TABLE customers ( customer_id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, full_name VARCHAR(200) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), is_active BOOLEAN NOT NULL DEFAULT TRUE);Choosing the Right Data Types
Picking the right data type matters for storage efficiency, query performance, and data integrity.
Numeric types:
-- Integer typesage SMALLINT, -- -32,768 to 32,767quantity INTEGER, -- -2.1B to 2.1Bproduct_count BIGINT, -- very large integers
-- Auto-incrementing integers (PostgreSQL)id SERIAL, -- INTEGER + auto-increment sequenceid BIGSERIAL, -- BIGINT + auto-increment sequence
-- Decimal (exact)price NUMERIC(10, 2), -- up to 10 digits, 2 decimal placestax_rate DECIMAL(5, 4), -- e.g., 0.0875
-- Floating point (approximate — avoid for money)lat DOUBLE PRECISION,lng REALText types:
name VARCHAR(200), -- variable-length up to 200 charscountry_code CHAR(2), -- fixed-length, padded with spacesdescription TEXT, -- unlimited lengthslug VARCHAR(100) -- short identifiersUse TEXT over VARCHAR without a length limit in PostgreSQL — there’s no performance difference and you avoid the constraint. Use CHAR(n) only for truly fixed-length fields like ISO country codes.
Date and time:
birth_date DATE, -- date only, no timecreated_at TIMESTAMP, -- date and time (no timezone)updated_at TIMESTAMPTZ, -- date, time, timezone (PostgreSQL)start_time TIME, -- time onlyduration INTERVAL -- time duration (PostgreSQL)Use TIMESTAMPTZ (timestamp with timezone) for any timestamp that represents a moment in time — not naive TIMESTAMP. This matters when users are in different timezones.
Boolean:
is_active BOOLEAN NOT NULL DEFAULT TRUE,verified BOOLEANJSON (PostgreSQL):
metadata JSONB, -- binary JSON, indexable, preferredraw_payload JSON -- text JSON, preserved as-isConstraints
Constraints enforce data integrity rules at the database level — they catch problems before bad data gets stored.
NOT NULL:
email VARCHAR(255) NOT NULL -- this column must always have a valueUNIQUE:
email VARCHAR(255) UNIQUE, -- no two rows can have the same email-- or as a table-level constraint:UNIQUE (first_name, last_name) -- combination must be uniquePRIMARY KEY:
-- Column-levelcustomer_id INTEGER PRIMARY KEY,
-- Table-level (required for composite keys)PRIMARY KEY (order_id, product_id)FOREIGN KEY:
CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL, amount NUMERIC(10, 2) NOT NULL, FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE RESTRICT -- prevent deleting a customer with orders ON UPDATE CASCADE -- if customer_id changes, update here too);Foreign key ON DELETE options:
RESTRICT— prevent the delete if referenced rows existCASCADE— delete child rows when parent is deletedSET NULL— set the FK column to NULL when parent is deletedNO ACTION— similar to RESTRICT, checked at end of transaction
CHECK:
price NUMERIC(10, 2) CHECK (price >= 0),status VARCHAR(20) CHECK (status IN ('pending', 'active', 'cancelled')),-- Table-level checkCHECK (end_date > start_date)DEFAULT:
status VARCHAR(20) NOT NULL DEFAULT 'pending',created_at TIMESTAMP NOT NULL DEFAULT NOW(),is_active BOOLEAN NOT NULL DEFAULT TRUEA Complete, Production-Style Table
CREATE TABLE products ( product_id SERIAL PRIMARY KEY, sku VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, description TEXT, price NUMERIC(10, 2) NOT NULL CHECK (price >= 0), cost NUMERIC(10, 2) CHECK (cost >= 0), category_id INTEGER NOT NULL REFERENCES categories(category_id) ON DELETE RESTRICT, stock_quantity INTEGER NOT NULL DEFAULT 0 CHECK (stock_quantity >= 0), is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), metadata JSONB,
CONSTRAINT chk_cost_below_price CHECK (cost IS NULL OR cost < price));
-- Index for category-based queries (foreign key doesn't auto-create an index in PostgreSQL)CREATE INDEX idx_products_category ON products(category_id);
-- Partial index for active products onlyCREATE INDEX idx_products_active_sku ON products(sku) WHERE is_active = TRUE;CREATE TABLE IF NOT EXISTS
Prevents an error when the table already exists — useful in migration scripts:
CREATE TABLE IF NOT EXISTS audit_log ( log_id BIGSERIAL PRIMARY KEY, table_name VARCHAR(100) NOT NULL, operation VARCHAR(10) NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE AS (CTAS)
Creates a new table and populates it from a SELECT query in one step. Useful for materializing complex queries or creating working tables:
-- Create a summary table from existing dataCREATE TABLE customer_summary ASSELECT customer_id, COUNT(*) AS total_orders, SUM(amount) AS lifetime_value, MAX(order_date) AS last_order_dateFROM ordersGROUP BY customer_id;Note: CREATE TABLE AS does not inherit constraints from the source — add them afterward with ALTER TABLE.
Temporary Tables
Temporary tables exist only for the duration of your session (or transaction, depending on the option):
CREATE TEMP TABLE staging_orders ASSELECT * FROM orders WHERE status = 'pending';
-- Explicit temp table with columnsCREATE TEMPORARY TABLE temp_results ( id INTEGER, result TEXT);Temp tables are useful in stored procedures, complex ETL steps, or when you need an intermediate result set that you’ll query multiple times.
Table Design Best Practices
Use surrogate keys, not natural keys, as primary keys. Email addresses, product codes, and phone numbers can change. An auto-increment integer or UUID as the primary key decouples identity from business data.
Every table should have a primary key. Without one, you can end up with duplicate rows you can’t distinguish.
Add created_at and updated_at to most tables. Knowing when records were created and last changed is almost always needed eventually.
Use TIMESTAMPTZ (with timezone), not TIMESTAMP. Applications in multiple timezones will thank you.
Name constraints explicitly. CONSTRAINT fk_orders_customer FOREIGN KEY ... is far easier to drop or reference in error messages than the auto-generated name orders_customer_id_fkey.
Think about indexes when creating tables. PostgreSQL doesn’t automatically create indexes on foreign keys (though primary keys and unique constraints do create indexes). Add indexes for columns you’ll filter and join on frequently.