A table’s definition is its schema — the list of columns, their data types, and the constraints that govern what values are valid. Choosing the right data type for each column is one of the most impactful database design decisions: the right type enables efficient storage, indexing, and querying; the wrong type wastes space, requires awkward casting, or silently allows invalid data. PostgreSQL has a rich type system — far beyond the standard integer, varchar, and boolean — that includes native support for JSON documents, arrays, date/time arithmetic, and UUIDs. Choosing well here pays dividends throughout the lifetime of the application.
CREATE TABLE Syntax
CREATE TABLE table_name (
column_name data_type [constraints],
...
[table_constraints]
);
-- Drop and recreate (development only — destroys all data)
DROP TABLE IF EXISTS posts;
CREATE TABLE posts ( ... );
-- Add a column to an existing table
ALTER TABLE posts ADD COLUMN excerpt TEXT;
-- Rename a column
ALTER TABLE posts RENAME COLUMN excerpt TO summary;
-- Drop a column
ALTER TABLE posts DROP COLUMN summary;
-- Change a column's type
ALTER TABLE posts ALTER COLUMN title TYPE VARCHAR(500);
TEXT and VARCHAR(n) are stored identically — there is no performance difference between them. PostgreSQL’s documentation recommends using TEXT for unrestricted strings and only adding a VARCHAR(n) length limit when there is a genuine business rule that limits the string (like a phone number format or a status code). Arbitrary length limits like VARCHAR(255) — inherited from MySQL’s historic limitations — serve no purpose in PostgreSQL.TIMESTAMPTZ (timestamp with time zone) rather than TIMESTAMP for all datetime columns. TIMESTAMP stores a “local time” with no timezone information — when you insert a value, PostgreSQL accepts it as-is. TIMESTAMPTZ converts inserted values to UTC internally and reconverts on retrieval based on the session timezone. For a web API with global users or deployed on servers in different timezones, always use TIMESTAMPTZ.FLOAT or DOUBLE PRECISION for monetary values. Floating-point numbers cannot represent many decimal fractions exactly — 0.1 + 0.2 in floating point is 0.30000000000000004, not 0.3. For money: use NUMERIC(precision, scale) (e.g., NUMERIC(12, 2) for up to 10 digits before and 2 after the decimal) or store amounts as integers (cents). PostgreSQL also has a MONEY type but it is locale-dependent and generally avoided.PostgreSQL Data Types Reference
| Category | Type | Use For | Notes |
|---|---|---|---|
| Integer | SMALLINT |
Small counts | -32768 to 32767 |
| Integer | INTEGER / INT |
IDs, counts | -2B to 2B |
| Integer | BIGINT |
Large IDs, timestamps | ±9.2 quintillion |
| Auto-increment | SERIAL |
Auto-inc INTEGER PK | Shorthand for sequence |
| Auto-increment | BIGSERIAL |
Auto-inc BIGINT PK | Preferred for most PKs |
| Decimal | NUMERIC(p, s) |
Money, precise math | Exact, no float rounding |
| Float | REAL / DOUBLE PRECISION |
Scientific data | Not for money! |
| Text | TEXT |
Unlimited strings | No performance penalty |
| Text | VARCHAR(n) |
Length-limited strings | Only if limit is real |
| Text | CHAR(n) |
Fixed-width (rare) | Padded with spaces |
| Boolean | BOOLEAN |
True/false flags | TRUE/FALSE/’t’/’f’/’yes’/’no’ |
| Date/Time | DATE |
Calendar date only | No time component |
| Date/Time | TIMESTAMPTZ |
Date + time with TZ | Stored as UTC |
| Date/Time | INTERVAL |
Duration | '7 days', '2 hours' |
| UUID | UUID |
Universally unique IDs | Use for public-facing IDs |
| JSON | JSONB |
Semi-structured data | Indexed, binary stored |
| JSON | JSON |
Exact JSON storage | Text, not indexed |
| Array | TEXT[], INT[] |
Lists of values | PostgreSQL-specific |
Constraints
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY, -- unique, not null, auto-inc
email TEXT NOT NULL UNIQUE, -- must be present, no duplicates
name VARCHAR(100) NOT NULL, -- must be present
age INTEGER CHECK (age >= 0 AND age <= 150), -- value range
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('user', 'editor', 'admin')), -- allowed values
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- defaults to current time
);
-- Named constraints (easier to identify in error messages)
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
author_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
CONSTRAINT posts_title_not_empty CHECK (LENGTH(TRIM(title)) > 0),
CONSTRAINT posts_status_valid CHECK (status IN ('draft', 'published', 'archived')),
CONSTRAINT posts_author_fk FOREIGN KEY (author_id) REFERENCES users(id)
);
-- Multi-column unique constraint (e.g. no duplicate slug per author)
CREATE TABLE posts (
...
author_id BIGINT NOT NULL,
slug TEXT NOT NULL,
UNIQUE (author_id, slug) -- this pair must be unique, each individually may repeat
);
Common Mistakes
Mistake 1 — Using FLOAT for monetary values
❌ Wrong — floating point rounding errors:
price FLOAT -- 0.1 + 0.2 = 0.30000000000000004
✅ Correct — use NUMERIC for exact arithmetic:
price NUMERIC(12, 2) -- exact: 9999999999.99 max ✓
Mistake 2 — Using TIMESTAMP instead of TIMESTAMPTZ
❌ Wrong — timezone-naive, problematic across environments:
created_at TIMESTAMP DEFAULT NOW() -- local time, no timezone
✅ Correct:
created_at TIMESTAMPTZ DEFAULT NOW() -- stored as UTC ✓
Mistake 3 — Using JSON instead of JSONB
❌ Wrong — JSON stores text, cannot be indexed:
metadata JSON -- text storage, no indexing support
✅ Correct — JSONB is binary, indexable, and faster to query:
metadata JSONB -- binary, indexed, faster ✓
Quick Reference
| Task | SQL |
|---|---|
| Create table | CREATE TABLE name (col type constraints, ...); |
| Drop table | DROP TABLE IF EXISTS name; |
| Add column | ALTER TABLE t ADD COLUMN col type; |
| Auto-increment PK | id BIGSERIAL PRIMARY KEY |
| Required field | col type NOT NULL |
| Unique field | col type UNIQUE |
| Default value | col type DEFAULT value |
| Value check | col type CHECK (expression) |