PostgreSQL Data Types: The Complete Guide
Choosing the right data type is the foundation of database design—it determines storage efficiency, query performance, and data precision.
1. What You'll Learn
- Integer types (SMALLINT / INTEGER / BIGINT)
- Auto-increment sequences (SERIAL / BIGSERIAL / IDENTITY)
- Floating-point and exact numerics (REAL / DOUBLE / DECIMAL / NUMERIC)
- String types (CHAR / VARCHAR / TEXT)
- Date/time types (DATE / TIME / TIMESTAMP / TIMESTAMPTZ / INTERVAL)
- Special types (UUID / INET / BOOLEAN / ENUM / JSONB)
2. A Developer's Real Story
(1) The Pain: Confused About Data Types
Bob ran into a series of tough choices when designing the tables for an e-commerce database:
- Should the user ID be INTEGER or BIGINT? What if the user count exceeds 2.1 billion?
- Should product prices use REAL or DECIMAL? Word is floating-point has precision issues.
- Should product descriptions use VARCHAR(5000) or TEXT?
- Should timestamps use TIMESTAMP or TIMESTAMPTZ? What about global users?
- Should IP addresses be stored as VARCHAR, or is there a dedicated type?
(2) The Solution: Precise Type Selection
PostgreSQL offers a rich set of dedicated data types; choosing precisely saves storage while guaranteeing precision:
-- Correct type choices for an e-commerce database
CREATE TABLE smart_products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- future-proof ID
name VARCHAR(200) NOT NULL, -- bounded string
description TEXT, -- unbounded text
price DECIMAL(10, 2) NOT NULL, -- exact money
weight_kg REAL, -- approximate is OK
is_available BOOLEAN DEFAULT true, -- yes/no flag
created_at TIMESTAMPTZ DEFAULT NOW(), -- timezone-aware
source_ip INET, -- IP address type
attributes JSONB DEFAULT '{}'::jsonb -- flexible schema
);
(3) The Payoff
- DECIMAL guarantees zero precision loss for money (89.99 always stays 89.99, never 89.9899999)
- TIMESTAMPTZ handles time zones automatically, so global users see their own local time
- INET supports IP-range queries, 10× more efficient than storing IPs as VARCHAR
- JSONB stores dynamic attributes flexibly, avoiding frequent ALTER TABLE changes
3. Data Type Decision Flow
graph TB
START[What kind of data?] --> NUM{Numeric?}
NUM -->|Yes| INT{Need exact<br/>precision?}
INT -->|Yes, money/rates| DEC[DECIMAL / NUMERIC]
INT -->|No, measurements| FLOAT[REAL / DOUBLE PRECISION]
NUM -->|Whole numbers| RANGE{Value range?}
RANGE -->|-32768 to 32767| SMALL[SMALLINT]
RANGE -->|-2.1B to 2.1B| INT2[INTEGER]
RANGE -->|Larger| BIG[BIGINT]
NUM -->|Auto-increment ID| AUTO[SERIAL / IDENTITY]
START --> STR{Text?}
STR -->|Fixed length| CHAR[CHAR]
STR -->|Bounded variable| VAR[VARCHAR n]
STR -->|Unbounded| TEXT2[TEXT]
START --> TIME{Date/Time?}
TIME -->|Date only| DATE2[DATE]
TIME -->|Time only| TIME2[TIME]
TIME -->|Timestamp| TZ{Timezone?}
TZ -->|Yes, global app| TSTZ[TIMESTAMPTZ]
TZ -->|No, local only| TS[TIMESTAMP]
TIME -->|Duration| IV[INTERVAL]
START --> SPEC{Special?}
SPEC -->|Yes/No| BOOL[BOOLEAN]
SPEC -->|UUID| UUID2[UUID]
SPEC -->|IP address| IP[INET / CIDR]
SPEC -->|Enum list| ENUM2[ENUM]
SPEC -->|JSON doc| JSON[JSONB]
4. Integer Types
| Type | Storage | Range | Typical use |
|---|---|---|---|
SMALLINT |
2 bytes | -32,768 ~ 32,767 | age, rating, status code |
INTEGER (INT) |
4 bytes | -2,147,483,648 ~ 2,147,483,647 | general integers, primary-key IDs |
BIGINT |
8 bytes | ±9,223,372,036,854,775,807 | large-table IDs, amounts (in cents) |
▶ Example: Choosing Integer Types
-- SMALLINT for small-range values
CREATE TABLE ratings (
user_id INTEGER REFERENCES users(id),
product_id INTEGER REFERENCES products(id),
score SMALLINT CHECK (score BETWEEN 1 AND 5), -- 1-5 is well within SMALLINT range
PRIMARY KEY (user_id, product_id)
);
-- INTEGER for most IDs
CREATE TABLE categories (
id SERIAL PRIMARY KEY, -- SERIAL = INTEGER + auto-increment sequence
name VARCHAR(100) NOT NULL
);
-- BIGINT for high-growth tables
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY, -- BIGSERIAL = BIGINT + auto-increment
action VARCHAR(50),
details JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Output:
CREATE TABLE
5. Auto-increment Sequences
(1) SERIAL vs IDENTITY
| Method | Syntax | Standard | Manual insert? | Recommended |
|---|---|---|---|---|
SERIAL |
id SERIAL PRIMARY KEY |
PG-specific | Yes | Legacy compatibility |
BIGSERIAL |
id BIGSERIAL PRIMARY KEY |
PG-specific | Yes | Legacy compatibility |
IDENTITY |
id INT GENERATED ALWAYS AS IDENTITY |
SQL standard | Needs OVERRIDING | ✅ New projects |
▶ Example: IDENTITY Columns (Recommended)
-- SQL-standard identity column (PostgreSQL 10+)
CREATE TABLE orders_v2 (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
total DECIMAL(12, 2) DEFAULT 0
);
-- Insert without specifying id (auto-generated)
INSERT INTO orders_v2 (user_id, total) VALUES (1, 99.99);
-- Attempt to manually insert id (will FAIL with GENERATED ALWAYS)
-- INSERT INTO orders_v2 (id, user_id, total) VALUES (100, 1, 50.00);
-- Use GENERATED BY DEFAULT if you need manual override sometimes
CREATE TABLE logs (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
message TEXT
);
Output:
INSERT 0 1
6. Floating-Point and Exact Numerics
(1) REAL / DOUBLE vs DECIMAL
| Type | Storage | Precision | Best for |
|---|---|---|---|
REAL |
4 bytes | 6 significant digits | scientific computing, sensors |
DOUBLE PRECISION |
8 bytes | 15 significant digits | scientific computing, statistics |
DECIMAL(p,s) |
variable | exact | money, rates (recommended) |
NUMERIC(p,s) |
variable | exact | same as DECIMAL |
▶ Example: Floating-Point Precision Pitfalls
-- Float precision trap: 0.1 + 0.2 != 0.3
SELECT 0.1::REAL + 0.2::REAL = 0.3::REAL AS float_equal;
-- Output: f (false!)
-- DECIMAL has no precision loss
SELECT 0.1::DECIMAL + 0.2::DECIMAL = 0.3::DECIMAL AS decimal_equal;
-- Output: t (true!)
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
▶ Example: Using DECIMAL for Money
-- DECIMAL(precision, scale)
-- precision = total digits, scale = digits after decimal point
-- DECIMAL(10,2) = up to 99,999,999.99
CREATE TABLE products_precise (
id SERIAL PRIMARY KEY,
name VARCHAR(200),
price DECIMAL(10, 2) NOT NULL, -- max 99,999,999.99
tax_rate DECIMAL(5, 4) DEFAULT 0.0875, -- 0.0875 = 8.75%
discount NUMERIC(5, 2) DEFAULT 0.00 -- max 999.99%
);
-- Money calculations are exact
SELECT name,
price,
price * tax_rate AS tax_amount,
price + (price * tax_rate) AS price_with_tax
FROM products_precise;
Output:
count
-------
5
(1 row)
| Precision choice | DECIMAL params | Max value | Best for |
|---|---|---|---|
| Small e-commerce | DECIMAL(8,2) |
999,999.99 | daily volume < 1M |
| Large e-commerce | DECIMAL(12,2) |
99,999,999,999.99 | global e-commerce |
| Crypto | DECIMAL(20,8) |
very large | BTC 8-decimal precision |
7. String Types
| Type | Storage | Max length | Best for |
|---|---|---|---|
CHAR(n) |
fixed-length, space-padded | n | hashes, ISO codes |
VARCHAR(n) |
variable-length | n | text with a length limit |
TEXT |
variable-length | unlimited | text without a length limit |
▶ Example: String Type Comparison
-- CHAR: fixed-length (padded with spaces)
SELECT LENGTH('abc'::CHAR(5));
-- Output: 5 (padded to 5 chars)
-- VARCHAR: variable-length with limit
SELECT LENGTH('abc'::VARCHAR(5));
-- Output: 3 (stored as-is, max 5)
-- TEXT: variable-length, no limit
SELECT LENGTH('abc'::TEXT);
-- Output: 3 (no limit)
-- Performance test: VARCHAR vs TEXT are identical in PG
-- (Unlike MySQL where VARCHAR is faster than TEXT)
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
8. Date and Time Types
| Type | Storage | Range | Best for |
|---|---|---|---|
DATE |
4 bytes | 4713 BC ~ 5874897 AD | date only (birthday, holiday) |
TIME |
8 bytes | 00:00:00 ~ 24:00:00 | time only (business hours) |
TIMESTAMP |
8 bytes | 4713 BC ~ 294276 AD | date+time (no time zone) |
TIMESTAMPTZ |
8 bytes | same as above | date+time+timezone (✅ recommended) |
INTERVAL |
16 bytes | ±178000000 years | time span |
▶ Example: TIMESTAMP vs TIMESTAMPTZ
-- Set timezone to UTC
SET timezone = 'UTC';
-- Insert the same moment in different types
INSERT INTO test_times (ts_no_tz, ts_with_tz) VALUES
('2026-07-13 10:00:00', '2026-07-13 10:00:00+00');
-- Change to Tokyo timezone
SET timezone = 'Asia/Tokyo';
-- TIMESTAMP (no timezone) shows the same literal
SELECT ts_no_tz FROM test_times;
-- Output: 2026-07-13 10:00:00 (unchanged, could be any timezone!)
-- TIMESTAMPTZ converts to local timezone
SELECT ts_with_tz FROM test_times;
-- Output: 2026-07-13 19:00:00+09 (10:00 UTC = 19:00 Tokyo)
Output:
INSERT 0 1
▶ Example: Date/Time Functions
-- Current date and time
SELECT NOW(); -- 2026-07-13 10:30:00.123456+00
SELECT CURRENT_DATE; -- 2026-07-13
SELECT CURRENT_TIMESTAMP; -- same as NOW()
-- Date arithmetic with INTERVAL
SELECT NOW() + INTERVAL '7 days'; -- one week from now
SELECT NOW() - INTERVAL '3 months'; -- 3 months ago
-- Age calculation
SELECT AGE(TIMESTAMP '1990-05-15'); -- 36 years 1 month 28 days
-- Extract parts
SELECT EXTRACT(YEAR FROM NOW()); -- 2026
SELECT EXTRACT(MONTH FROM NOW()); -- 7
SELECT EXTRACT(DOW FROM NOW()); -- 1 (Monday, 0=Sunday)
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
9. Special Types
(1) UUID
-- Enable uuid-ossp extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Generate different UUID versions
SELECT uuid_generate_v4(); -- random UUID (most common)
SELECT uuid_generate_v1(); -- time-based UUID
| UUID version | Generation | Best for |
|---|---|---|
| v1 | timestamp + MAC address | time-ordered |
| v4 | random | most scenarios (recommended) |
| v7 | timestamp + random (new standard) | time-ordered + random |
(2) INET / CIDR (Network Addresses)
▶ Example: Storing and Querying IP Addresses
-- INET: single IP or IP range
CREATE TABLE access_log (
id BIGSERIAL PRIMARY KEY,
source_ip INET NOT NULL,
access_time TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO access_log (source_ip) VALUES
('192.168.1.100'),
('10.0.0.5'),
('2001:db8::1');
-- Query: find all IPs in a subnet (impossible with VARCHAR!)
SELECT source_ip FROM access_log
WHERE source_ip << '192.168.1.0/24'::INET;
-- << means "is contained within"
-- CIDR: network range
SELECT '192.168.1.0/24'::CIDR;
Output:
INSERT 0 1
| Operator | Meaning | Example |
|---|---|---|
<< |
contained in | 192.168.1.5' << '192.168.1.0/24' = true |
>> |
contains | '192.168.1.0/24' >> '192.168.1.5' = true |
= |
equals | '192.168.1.5'::INET = '192.168.1.5'::INET |
(3) BOOLEAN
▶ Example: Three Representations of Boolean
-- Boolean accepts multiple representations
SELECT true, 't', 'true', 'yes', 'on', '1'; -- all = TRUE
SELECT false, 'f', 'false', 'no', 'off', '0'; -- all = FALSE
-- Boolean in WHERE clause
SELECT name FROM products WHERE is_available IS TRUE;
SELECT name FROM products WHERE is_available IS NOT FALSE;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
(4) ENUM
▶ Example: Custom Enum Types
-- Create an enum type (once, reusable across tables)
CREATE TYPE order_status AS ENUM (
'pending', 'paid', 'shipped', 'delivered', 'cancelled'
);
-- Use in table definition
CREATE TABLE orders_enum (
id SERIAL PRIMARY KEY,
status order_status DEFAULT 'pending'
);
-- Enum values are validated automatically
INSERT INTO orders_enum (status) VALUES ('pending'); -- OK
-- INSERT INTO orders_enum (status) VALUES ('unknown'); -- ERROR!
Output:
INSERT 0 1
ALTER TYPE ... ADD VALUE (allowed inside a transaction since PG 9.1). If the values change often, a VARCHAR + CHECK constraint is more flexible.
10. Data Type Comparison Table
| Need | ❌ Not recommended | ✅ Recommended | Reason |
|---|---|---|---|
| Money | REAL / FLOAT | DECIMAL(p,s) | floating-point loses precision |
| Timestamp | TIMESTAMP | TIMESTAMPTZ | global apps need time zones |
| IP address | VARCHAR | INET | INET supports range queries |
| Yes/No | INTEGER (0/1) | BOOLEAN | clearer semantics |
| Long text | VARCHAR(9999) | TEXT | TEXT has no limit and same performance |
| Status enum | VARCHAR + CHECK | ENUM or VARCHAR+CHECK | ENUM if stable, CHECK if frequently changing |
| Flexible attributes | many NULL columns | JSONB | one column for dynamic fields |
| Auto-increment ID | SERIAL | IDENTITY | SQL-standard syntax |
11. Complete Example: A Table Using Many Types
-- ============================================
-- Complete example: table with all major types
-- A sensor data collection system
-- ============================================
-- Create enum type
CREATE TYPE sensor_status AS ENUM ('active', 'inactive', 'maintenance');
-- Create table with diverse data types
CREATE TABLE sensors (
-- Identity
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uuid UUID DEFAULT uuid_generate_v4() UNIQUE,
-- Text
name VARCHAR(100) NOT NULL,
description TEXT,
location_code CHAR(10),
-- Numeric
latitude DECIMAL(9, 6) CHECK (latitude BETWEEN -90 AND 90),
longitude DECIMAL(9, 6) CHECK (longitude BETWEEN -180 AND 180),
altitude REAL,
-- Network
ip_address INET,
subnet CIDR,
-- Status
status sensor_status DEFAULT 'active',
is_online BOOLEAN DEFAULT false,
-- Time
installed_at DATE,
last_reading_at TIMESTAMPTZ,
reading_interval INTERVAL DEFAULT INTERVAL '5 minutes',
-- Flexible data
metadata JSONB DEFAULT '{}'::jsonb,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert sample data
INSERT INTO sensors (name, latitude, longitude, ip_address, status, installed_at, metadata)
VALUES (
'Temperature Sensor A1',
35.6762, 139.6503,
'192.168.1.50',
'active',
'2025-01-15',
'{"model": "TX-200", "unit": "celsius", "range_min": -40, "range_max": 85}'::jsonb
);
-- Query with type-specific operators
SELECT name, ip_address, metadata->>'model' AS model
FROM sensors
WHERE ip_address << '192.168.1.0/24'::INET
AND status = 'active';
❓ FAQ
📖 Summary
- Integer choice: SMALLINT (small range) / INTEGER (default) / BIGINT (large-table IDs); prefer IDENTITY for auto-increment
- Money must use DECIMAL; floating-point types lose precision
- Strings: VARCHAR (length-limited) / TEXT (unlimited), with identical performance
- Time: TIMESTAMPTZ (timezone-aware) is recommended; INTERVAL is for time arithmetic
- Special types: UUID (distributed IDs) / INET (IP addresses) / BOOLEAN (yes/no) / ENUM (fixed enumerations) / JSONB (flexible attributes)
- Core principle: pick the most precise type; when in doubt, err on the larger side (BIGINT costs 4 extra bytes per row, but rewriting a large table via ALTER TABLE costs far more)
📝 Exercises
-
Basic (★): Create a
countriestable with: id (SERIAL primary key), name (VARCHAR(100)), iso_code (CHAR(2)), population (BIGINT), gdp DECIMAL(15,2), is_developed (BOOLEAN). Insert 3 test rows. -
Intermediate (★★): Create a
server_logstable with: id (BIGINT IDENTITY primary key), source_ip (INET), request_time (TIMESTAMPTZ), response_time_ms (INTEGER), is_error (BOOLEAN), metadata (JSONB). Insert 2 log records, then query all logs from the10.0.0.0/8network. -
Challenge (★★★): Design a
financial_transactionstable that handles multiple currencies (USD/EUR/JPY/CNY) with different amount precisions (USD/EUR: 2 decimals, JPY: 0 decimals, CNY: 2 decimals). Implement it using DECIMAL + CHECK constraints so different currencies require different decimal scales. Write the CREATE TABLE SQL plus 3 INSERT examples for different currencies.



