SQLMentor // learn postgresql

PostgreSQL Data Types

PostgreSQL has one of the richest type systems of any database. Choosing the right type for each column matters — it affects storage, performance, indexing, and data integrity. This chapter covers the most important types and when to use each one.

Numeric Types

Type Storage Range Use When
SMALLINT 2 bytes -32,768 to 32,767 Small integer values (e.g., age, rating 1-5)
INTEGER (or INT) 4 bytes -2.1B to 2.1B General-purpose integer
BIGINT 8 bytes ±9.2 × 10^18 Large counters, IDs at scale
NUMERIC(p,s) Variable Exact decimal Money, measurements needing exact precision
REAL 4 bytes ~6 decimal digits Approximate float (scientific data)
DOUBLE PRECISION 8 bytes ~15 decimal digits Higher-precision float
CREATE TABLE products (
    id           BIGINT PRIMARY KEY,
    quantity     INTEGER,
    price        NUMERIC(10, 2),   -- up to 10 digits, 2 after decimal point
    weight_kg    REAL,
    rating       SMALLINT CHECK (rating BETWEEN 1 AND 5)
);

-- NUMERIC is exact; FLOAT is approximate
SELECT 0.1 + 0.2;                   -- returns 0.30000000000000004 (float)
SELECT 0.1::NUMERIC + 0.2::NUMERIC; -- returns 0.3 (exact)
Never use REAL or FLOAT for money. Use NUMERIC(12,2) — floating-point arithmetic is inexact and will cause rounding errors in financial calculations.

Character Types

Type Description
CHAR(n) Fixed-length, right-padded with spaces
VARCHAR(n) Variable-length with limit
TEXT Variable-length, no limit
-- All three store the same values, but TEXT is preferred in PostgreSQL
CREATE TABLE demo (
    code     CHAR(3),       -- always 3 chars, padded: 'US ' not 'US'
    name     VARCHAR(100),  -- up to 100 chars
    bio      TEXT           -- unlimited length
);
In PostgreSQL, there is no performance difference between TEXT and VARCHAR(n) — they use the same storage mechanism. Use TEXT unless you have a specific reason to enforce a length limit (e.g., a 2-letter country code). Avoid CHAR(n) — the trailing-space padding causes subtle comparison bugs.

Date and Time Types

This is where PostgreSQL truly shines — and where beginners make the most mistakes.

Type Storage Description
DATE 4 bytes Calendar date only (no time)
TIME 8 bytes Time of day (no date, no timezone)
TIMESTAMP 8 bytes Date + time, no timezone
TIMESTAMPTZ 8 bytes Date + time, with timezone
INTERVAL 16 bytes Duration (e.g., '3 days', '2 hours')

The Critical TIMESTAMP vs TIMESTAMPTZ Difference

TIMESTAMP stores the raw date/time values with no timezone context. TIMESTAMPTZ (timestamp with time zone) stores the moment in UTC and displays it in the session's timezone.

-- Set different timezone for demonstration
SET timezone = 'America/New_York';

INSERT INTO events (name, ts_notz, ts_withtz) VALUES (
    'conference',
    '2024-03-15 10:00:00',           -- stored exactly as-is
    '2024-03-15 10:00:00'            -- stored as UTC (15:00:00 UTC)
);

SET timezone = 'Asia/Tokyo';

SELECT name, ts_notz, ts_withtz FROM events;
-- ts_notz:   2024-03-15 10:00:00      (unchanged — no conversion)
-- ts_withtz: 2024-03-16 00:00:00+09   (converted to Tokyo time correctly!)
Always use TIMESTAMPTZ for application timestamps. Using TIMESTAMP (without timezone) means you lose the context of when something actually happened when users are in different timezones or when your server moves between regions.

Working with Dates and Times

-- Current timestamp (timezone-aware)
SELECT NOW();               -- 2024-03-15 14:23:11.456789+00
SELECT CURRENT_TIMESTAMP;   -- same
SELECT CURRENT_DATE;        -- 2024-03-15
SELECT CURRENT_TIME;        -- 14:23:11.456789+00

-- Interval arithmetic
SELECT NOW() + INTERVAL '7 days';           -- one week from now
SELECT NOW() - INTERVAL '3 months';         -- 3 months ago
SELECT '2024-12-31'::DATE - '2024-01-01'::DATE;  -- 365 (days)

-- Extract parts
SELECT EXTRACT(YEAR  FROM NOW());   -- 2024
SELECT EXTRACT(MONTH FROM NOW());   -- 3
SELECT EXTRACT(DOW   FROM NOW());   -- 0=Sunday, 1=Monday, ...
SELECT DATE_TRUNC('month', NOW());  -- 2024-03-01 00:00:00+00

-- Format a date
SELECT TO_CHAR(NOW(), 'YYYY-MM-DD HH24:MI:SS');  -- 2024-03-15 14:23:11

-- Parse a string into a date
SELECT TO_DATE('15/03/2024', 'DD/MM/YYYY');  -- 2024-03-15

Boolean

-- Boolean literals
SELECT TRUE, FALSE, NULL;

-- Valid input values (PostgreSQL is lenient)
-- TRUE:  true, yes, on, 1, t, y
-- FALSE: false, no, off, 0, f, n

CREATE TABLE feature_flags (
    feature_name TEXT,
    enabled      BOOLEAN DEFAULT FALSE
);

INSERT INTO feature_flags VALUES ('dark_mode', TRUE);
INSERT INTO feature_flags VALUES ('beta_ui',   'yes');   -- also valid
INSERT INTO feature_flags VALUES ('analytics', '1');     -- also valid

SELECT feature_name FROM feature_flags WHERE enabled;  -- WHERE enabled = TRUE

Network Address Types

PostgreSQL has built-in types for network addresses — far better than storing them as strings.

CREATE TABLE servers (
    hostname   TEXT,
    ip_address INET,    -- IPv4 or IPv6 address, optionally with subnet
    network    CIDR,    -- Network address (host bits must be zero)
    mac_addr   MACADDR  -- MAC address
);

INSERT INTO servers VALUES
    ('web01',   '192.168.1.10',      '192.168.1.0/24',   '08:00:2b:01:02:03'),
    ('db01',    '10.0.0.5/16',       '10.0.0.0/8',       '08:00:2b:04:05:06');

-- Find all IPs in a subnet
SELECT hostname FROM servers
WHERE ip_address << '192.168.0.0/16';  -- << means "contained within"

-- Find the network address of an IP
SELECT host(ip_address), masklen(ip_address) FROM servers;
Operator Meaning
<< Is contained within subnet
>> Contains subnet
&& Overlaps with subnet

UUID

-- Enable the uuid-ossp extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
    id         UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    username   TEXT NOT NULL
);

INSERT INTO users (username) VALUES ('alice');
-- id is automatically generated: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'

-- PostgreSQL 13+ has gen_random_uuid() built in (no extension needed)
CREATE TABLE sessions (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id    UUID REFERENCES users(id)
);
Use UUIDs when you need globally unique IDs (across systems, databases, or before inserting). Use BIGSERIAL/IDENTITY when you need simple sequential IDs. UUIDs are larger (16 bytes vs 4-8 bytes) and slightly slower for range scans due to random ordering.

Enumerated Types

-- Create an enum type
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');

CREATE TABLE orders (
    id       SERIAL PRIMARY KEY,
    status   order_status NOT NULL DEFAULT 'pending'
);

INSERT INTO orders (status) VALUES ('processing');
INSERT INTO orders (status) VALUES ('invalid');  -- ERROR: invalid input value for enum

-- Enums have a sort order
SELECT unnest(enum_range(NULL::order_status));
-- pending, processing, shipped, delivered, cancelled

SELECT * FROM orders WHERE status > 'pending';  -- later in enum sequence
Enum values cannot be removed (only added with ALTER TYPE ... ADD VALUE). If you need flexible sets that change over time, use a reference table with a foreign key instead. Enums work well for stable, small sets like statuses and categories.

JSON and JSONB (Preview)

JSON and JSONB are covered in detail in chapter 17, but here is the key difference:

Type Storage Indexable Preserves whitespace/key order
JSON Text No Yes
JSONB Binary Yes (GIN) No
CREATE TABLE settings (
    user_id INTEGER,
    prefs   JSONB  -- always use JSONB unless you need exact whitespace preservation
);

Arrays (Preview)

-- Any type can be an array
CREATE TABLE tags_demo (
    post_id INTEGER,
    tags    TEXT[],
    scores  INTEGER[]
);

INSERT INTO tags_demo VALUES (1, ARRAY['postgresql', 'database'], ARRAY[95, 87]);
INSERT INTO tags_demo VALUES (2, '{sql, beginner}', '{70, 82}');

Type Casting

PostgreSQL uses :: for casting:

SELECT '42'::INTEGER;             -- text to integer
SELECT 42::TEXT;                  -- integer to text
SELECT '2024-03-15'::DATE;        -- text to date
SELECT '3.14'::NUMERIC;           -- text to numeric
SELECT NOW()::DATE;               -- timestamp to date (drops time)

-- CAST() is the SQL-standard equivalent
SELECT CAST('42' AS INTEGER);

Type Comparison Quick Reference

Which type should I use?

For IDs:

  • Small table (< 2 billion rows): SERIAL or GENERATED ALWAYS AS IDENTITY
  • Large table or distributed: BIGSERIAL/BIGINT GENERATED ALWAYS AS IDENTITY
  • Cross-system unique IDs: UUID

For strings:

  • General text: TEXT
  • Enforce max length: VARCHAR(n)
  • Fixed-width codes: CHAR(n) (use sparingly)

For numbers:

  • Counts, IDs: INTEGER or BIGINT
  • Money, exact decimal: NUMERIC(p,s)
  • Scientific/float data: DOUBLE PRECISION

For dates/times:

  • Date only: DATE
  • Application timestamps: TIMESTAMPTZ (almost always)
  • Duration: INTERVAL

For structured data:

  • Flexible attributes: JSONB
  • Lists of same-type values: type[] (array)
  • Small, stable category: ENUM