Key Takeaways

  • PostgreSQL's BOOLEAN, UUID, and native array types have no direct MySQL equivalents. They require workarounds (TINYINT(1), CHAR(36), and junction tables) in MySQL.
  • Use TIMESTAMPTZ (not plain TIMESTAMP) for almost all datetime columns. It stores UTC and converts to the client timezone automatically, with no 2038 ceiling.
  • Prefer JSONB over JSON. JSONB stores data in binary format for faster queries and supports GIN indexes. Plain JSON preserves input text and is slower to query.
  • Use GENERATED ALWAYS AS IDENTITY over SERIAL in new schemas. It's the SQL standard and gives explicit sequence control.
  • Use NUMERIC(p,s) for money. REAL and DOUBLE PRECISION use IEEE 754 floating-point and can't store most decimal fractions exactly.
Code on a monitor screen representing PostgreSQL database schema design and data type selection
PostgreSQL's type system is richer than MySQL's. Choosing the right type at schema design time avoids expensive migrations later.

Numeric Types

PostgreSQL ships six standard numeric types, from 2-byte SMALLINT to variable-length arbitrary-precision NUMERIC. The critical split is exact versus approximate storage. The PostgreSQL documentation defines NUMERIC as storing values exactly as entered, with no rounding, up to 131,072 digits before the decimal point. That's what makes it the correct choice for money and any calculation where rounding errors compound.

Type Storage Range Use for
SMALLINT 2 bytes -32,768 to 32,767 Small counters, age, status codes
INTEGER / INT 4 bytes -2.1B to 2.1B General-purpose IDs and counts
BIGINT 8 bytes ±9.2 quintillion High-volume tables, snowflake IDs
NUMERIC(p,s) / DECIMAL(p,s) Variable Up to 131,072 digits before decimal Money, precise measurements
REAL 4 bytes ~6 decimal digits precision Scientific values where small errors are acceptable
DOUBLE PRECISION 8 bytes ~15 decimal digits precision Scientific computing, GIS coordinates
PostgreSQL Numeric Type Storage Sizes Numeric Type — Storage Size (bytes) SMALLINT 2 bytes REAL 4 bytes (approx.) INTEGER 4 bytes NUMERIC(10,2) ~6 bytes (exact) DOUBLE PRECISION 8 bytes (approx.) BIGINT 8 bytes Integer (exact) Float (approximate) Fixed-point (exact) Source: PostgreSQL Documentation — postgresql.org/docs/current/datatype-numeric.html
Storage bytes per PostgreSQL numeric type. Green = exact integers, yellow = approximate floating-point, blue = fixed-point NUMERIC.

Key rules:

  • NUMERIC and DECIMAL are aliases. They're identical in PostgreSQL. Use NUMERIC(10,2) for monetary columns.
  • Never use REAL or DOUBLE PRECISION for money. Both use IEEE 754 binary floating-point, which can't represent most decimal fractions exactly.
  • Unlike MySQL, PostgreSQL has no UNSIGNED integers. Use BIGINT if you need a larger positive range than INTEGER provides.
  • PostgreSQL doesn't have TINYINT. Use SMALLINT for small integer columns, or BOOLEAN for flags.

PostgreSQL's NUMERIC type stores values with user-specified precision and scale, with no rounding at any step, in contrast to REAL and DOUBLE PRECISION which use IEEE 754 binary floating-point representation (PostgreSQL Documentation). For monetary values, NUMERIC(10,2) is the standard: it stores exactly two decimal places with no floating-point drift, regardless of how many rows accumulate.

Text Types

PostgreSQL's three text types share the same underlying storage engine. That's worth knowing upfront because it changes how you choose between them. The PostgreSQL documentation is explicit: VARCHAR(n), CHAR(n), and TEXT all use identical storage. There's no performance difference between TEXT and VARCHAR in PostgreSQL. You don't need prefix indexes to search a TEXT column, and both can be indexed directly with B-tree or GIN.

Type Constraint Use for
CHAR(n) Exactly n chars (space-padded) Fixed-length codes: country codes (CHAR(2)), MD5 hashes
VARCHAR(n) Up to n chars Length-bounded strings: emails, slugs, titles
TEXT Unlimited Any string content; same performance as VARCHAR in PostgreSQL

Key rules:

  • Use TEXT for string columns without a known maximum length. PostgreSQL stores and queries it identically to VARCHAR.
  • Use VARCHAR(n) when you want the database to enforce a maximum length at insert time, for example VARCHAR(255) for email addresses.
  • Use CHAR(n) only for truly fixed-length values: ISO country codes, currency codes, fixed hash strings.
  • Unlike MySQL, TEXT in PostgreSQL doesn't require a prefix length for B-tree indexes and can appear in any WHERE clause without a full-table scan when indexed.

In PostgreSQL, TEXT and VARCHAR(n) use the same storage mechanism and have identical performance characteristics (PostgreSQL Documentation). The only practical difference is that VARCHAR(n) enforces a maximum character count at the database level. Unlike MySQL, PostgreSQL TEXT columns can be indexed directly with B-tree, with no prefix length required.

Boolean — Native True/False Storage

PostgreSQL has a native BOOLEAN type that stores true, false, or NULL natively, with no convention required. This is a real difference from MySQL, which has no native boolean type and uses TINYINT(1) as a convention, requiring ORM mapping to handle boolean coercion correctly. The PostgreSQL documentation lists multiple accepted input literals: TRUE/FALSE, 't'/'f', 'yes'/'no', 'on'/'off', and 1/0.

CREATE TABLE user_settings (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    BIGINT NOT NULL REFERENCES users(id),
    email_opt  BOOLEAN NOT NULL DEFAULT true,
    dark_mode  BOOLEAN NOT NULL DEFAULT false,
    verified   BOOLEAN NOT NULL DEFAULT false
);

In practice, stick to TRUE/FALSE literals in application code. PostgreSQL accepts the abbreviated forms, but explicit literals are clearer in queries and migrations. ORMs like Django, Rails, and Laravel all handle PostgreSQL's BOOLEAN type natively without extra column configuration. Contrast this with MySQL, where ORM-level boolean columns silently map to TINYINT(1) and can store values other than 0 and 1. Building SQL Designer's schema export tool, we ran into exactly this gap when generating MySQL DDL from PostgreSQL boolean columns: the types don't map 1:1, and that difference shows up at runtime.

PostgreSQL's native BOOLEAN type stores true, false, or NULL, accepting multiple input formats including TRUE/FALSE, 't'/'f', 'yes'/'no', and 1/0 (PostgreSQL Documentation). MySQL has no equivalent native type. It represents booleans as TINYINT(1), a convention that ORMs must explicitly map and one that doesn't enforce the 0/1 constraint at the database level.

Date and Time Types

PostgreSQL's datetime type set is more expressive than MySQL's. The most important decision is TIMESTAMP (no timezone) versus TIMESTAMPTZ (with timezone). According to the PostgreSQL documentation, TIMESTAMPTZ stores all values as UTC and converts to the session's TimeZone setting on output. This is behaviorally equivalent to MySQL's TIMESTAMP, but without MySQL's hard ceiling of 2038-01-19.

Type Range Use for
DATE 4713 BC to 5874897 AD Dates without time: birthdays, deadlines
TIME 00:00:00 to 24:00:00 Time-of-day without a date component
TIMESTAMP 4713 BC to 294276 AD Wall-clock datetime stored without timezone conversion
TIMESTAMPTZ 4713 BC to 294276 AD Audit timestamps (created_at, updated_at), stored as UTC
INTERVAL ±178,000,000 years Durations, date arithmetic: INTERVAL '7 days', INTERVAL '1 month'
TIMESTAMPTZ vs MySQL TIMESTAMP: Key Differences Timestamp Types: PostgreSQL vs MySQL Feature PostgreSQL TIMESTAMPTZ MySQL TIMESTAMP Stores as UTC Yes Yes Upper limit Year 294276 2038-01-19 Storage 8 bytes 4 bytes TZ conversion on output Automatic Automatic DEFAULT NOW() Yes Yes Source: PostgreSQL Documentation — postgresql.org/docs/current/datatype-datetime.html
TIMESTAMPTZ and MySQL's TIMESTAMP both store UTC and auto-convert on output. TIMESTAMPTZ uses 8 bytes and has no 2038 ceiling.

Key rules:

  • Use TIMESTAMPTZ for created_at, updated_at, and any event or log timestamp. It's timezone-safe and has no 2038 problem.
  • Use plain TIMESTAMP only when you intentionally want wall-clock time without timezone conversion. That's rare in practice.
  • Use INTERVAL for storing durations: subscription lengths, time-since-last-action, recurring event offsets.
  • PostgreSQL's DATE range extends to 5874897 AD, and back to 4713 BC. Useful for historical data and long-term scheduling applications.

PostgreSQL's TIMESTAMPTZ stores all values internally as UTC and converts them to the client session's TimeZone setting on retrieval (PostgreSQL Documentation). Unlike MySQL's TIMESTAMP, which has a hard ceiling of 2038-01-19 due to 32-bit Unix timestamp overflow, TIMESTAMPTZ's range extends to year 294276, making it safe for any date that might ever need to be stored.

JSON and JSONB — Binary JSON Storage

PostgreSQL offers two JSON types, and the difference isn't cosmetic. The PostgreSQL documentation explains that JSON stores the input text verbatim, preserving whitespace, key order, and duplicate keys. JSONB, by contrast, parses the document into a binary decomposition on insert. It discards whitespace, deduplicates keys, and rewrites key order. That extra write cost makes subsequent reads and queries significantly faster. JSONB also supports GIN indexes for full-document search and the containment operators (@>, <@) that make querying inside JSON practical at scale.

So when would you pick plain JSON? Almost never. The only reason to reach for it is when preserving exact input representation matters, for example when auditing or replaying an exact API payload. For everything else, JSONB is the right call.

-- Create a table with JSONB for flexible metadata
CREATE TABLE products (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        TEXT NOT NULL,
    price       NUMERIC(10,2) NOT NULL,
    attributes  JSONB
);

-- Insert with JSON literal
INSERT INTO products (name, price, attributes)
VALUES ('Wireless Keyboard', 79.99, '{"color": "black", "in_stock": true, "tags": ["electronics", "peripherals"]}');

-- Query inside JSONB using ->> (returns text)
SELECT name, attributes->>'color'
FROM products
WHERE attributes @> '{"in_stock": true}';

-- Index a specific JSONB path for fast lookups
CREATE INDEX ON products ((attributes->>'category'));

-- Full-document GIN index for containment queries
CREATE INDEX ON products USING GIN (attributes);
Abstract glowing data visualization lines on a dark background representing complex database structures and JSON data storage in PostgreSQL
JSONB stores documents in a binary decomposition, enabling GIN indexes and containment operators not available with plain JSON.

MySQL 5.7+ also has a JSON type, but it lacks PostgreSQL's JSONB binary format and GIN index support. If you're coming from MySQL, think of JSONB as a significantly more capable version: queryable with containment operators, indexable without generated columns, and faster on read-heavy workloads. See our MySQL vs PostgreSQL comparison for a deeper look at how the two databases handle JSON.

PostgreSQL's JSONB type stores JSON documents in a parsed binary format, enabling GIN index support and containment operators like @> and <@ (PostgreSQL Documentation). Unlike the plain JSON type, which stores input text verbatim, JSONB eliminates whitespace, deduplicates keys, and rewrites key order, making it faster for most read and query workloads at the cost of a slightly slower write.

Citus Data's conference talk on advanced PostgreSQL types: arrays, JSONB, composite types, and range types. Worth watching before finalizing your schema.

Arrays — A PostgreSQL-Only Feature

PostgreSQL supports native array columns for any built-in data type. There's no MySQL equivalent. Arrays let you store multiple values of the same type in a single column without a junction table. The PostgreSQL documentation confirms that arrays are fully indexable with GIN indexes and support operators like ANY, ALL, containment (@>), and overlap (&&) for efficient filtering.

-- Store tags as a TEXT array
CREATE TABLE articles (
    id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title     TEXT NOT NULL,
    tags      TEXT[],
    scores    INTEGER[]
);

-- Insert with ARRAY constructor
INSERT INTO articles (title, tags)
VALUES ('PostgreSQL Guide', ARRAY['postgresql', 'database', 'sql']);

-- Find articles tagged 'postgresql'
SELECT title FROM articles
WHERE 'postgresql' = ANY(tags);

-- Find articles with both 'database' and 'sql' tags (containment)
SELECT title FROM articles
WHERE tags @> ARRAY['database', 'sql'];

-- GIN index for array membership queries
CREATE INDEX ON articles USING GIN (tags);

Arrays are useful for tags, permission sets, and small bounded value lists. Use them deliberately, though. If you frequently join on array values, or the array can grow without bound, a normalized junction table is the better design. Arrays shine when the set is small, bounded, and queried with ANY, not as a surrogate for a proper relation. We use TEXT[] for tag columns in SQL Designer's internal schema and it works well. The moment we needed to join on those values across tables, we moved them to a relation. Use SQL Designer to sketch the tradeoff between an array column and a junction table before committing.

PostgreSQL supports native array columns for any built-in type, stored as a single column value with full operator support including ANY, ALL, containment (@>), and overlap (&&) (PostgreSQL Documentation). GIN indexes on array columns make tag-based and membership filtering efficient without a junction table, though normalized relations remain preferable for unbounded or heavily joined datasets.

UUID and Identity Columns

PostgreSQL has a native UUID type that stores a 128-bit value in 16 bytes. That's more efficient than MySQL's CHAR(36) (36 bytes of text) and equivalent to MySQL's BINARY(16) without needing manual conversion functions. Since PostgreSQL 13, gen_random_uuid() is a built-in function that generates version 4 UUIDs with no extension required. The PostgreSQL documentation notes that UUID values are accepted in any standard format: with hyphens, without, or with curly braces.

-- UUID primary key (PostgreSQL 13+, no extension needed)
CREATE TABLE users (
    id         UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- GENERATED AS IDENTITY — SQL standard, preferred over SERIAL
CREATE TABLE orders (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id      UUID NOT NULL REFERENCES users(id),
    total        NUMERIC(10,2) NOT NULL,
    placed_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- SERIAL (legacy — still works, but creates implicit sequence)
CREATE TABLE events (
    id        SERIAL PRIMARY KEY,
    name      TEXT NOT NULL
);

For auto-incrementing integer primary keys, PostgreSQL offers two approaches. The legacy SERIAL pseudo-type creates an implicit sequence. It works, but the sequence is loosely coupled and can cause surprises during dumps and restores. The modern alternative is GENERATED ALWAYS AS IDENTITY (SQL:2003 standard, available since PostgreSQL 10). It provides identical behavior with explicit sequence ownership and is compatible with standards-based tooling. Prefer it in new schemas. For the MySQL equivalent, see our guide to MySQL data types.

UUID Storage: PostgreSQL vs MySQL UUID Storage by Approach PostgreSQL UUID (native) 16 bytes MySQL BINARY(16) 16 bytes (manual UUID_TO_BIN) MySQL CHAR(36) 36 bytes (text, 2.25× larger) Compact binary Text representation Source: PostgreSQL Documentation — postgresql.org/docs/current/datatype-uuid.html
PostgreSQL's native UUID type stores 16 bytes without conversion. MySQL CHAR(36) uses 36 bytes, 2.25 times the storage, with no automatic optimization.

PostgreSQL's UUID type stores a 128-bit identifier as 16 bytes natively, with no manual conversion required (PostgreSQL Documentation). Since PostgreSQL 13, gen_random_uuid() generates version 4 UUIDs as a built-in function without any extension. This compares favorably to MySQL's CHAR(36), which uses 36 bytes of text and requires UUID_TO_BIN() to achieve equivalent compact storage.

Quick Reference: Common Column Patterns

Translating all of the above into practice, these are the column definitions you'll actually write in day-to-day PostgreSQL schemas. Each pattern reflects the type guidance covered in this guide. When you're unsure whether to use UUID or BIGINT for a primary key, or TIMESTAMPTZ vs plain TIMESTAMP, this list is a good starting point.

  • Auto-increment primary key (modern): BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
  • Auto-increment primary key (legacy): BIGSERIAL PRIMARY KEY
  • UUID primary key (PG 13+): UUID DEFAULT gen_random_uuid() PRIMARY KEY
  • Email address: VARCHAR(255) NOT NULL UNIQUE
  • Password hash: TEXT NOT NULL
  • URL slug: TEXT NOT NULL
  • Price / monetary value: NUMERIC(10, 2) NOT NULL
  • Boolean flag: BOOLEAN NOT NULL DEFAULT false
  • Audit timestamp (UTC-aware): TIMESTAMPTZ NOT NULL DEFAULT NOW()
  • Long-form text content: TEXT
  • JSON metadata: JSONB
  • Tags / string list: TEXT[]
  • Integer foreign key: BIGINT NOT NULL REFERENCES other_table(id)
  • UUID foreign key: UUID NOT NULL REFERENCES other_table(id)
  • Status / enum: TEXT NOT NULL CHECK (status IN ('active', 'inactive', 'pending'))

Frequently Asked Questions

What is the difference between NUMERIC and DECIMAL in PostgreSQL?

NUMERIC and DECIMAL are identical in PostgreSQL. They're aliases for the same exact-precision type. Both accept NUMERIC(precision, scale) and DECIMAL(precision, scale) with the same behavior. Use either; NUMERIC is slightly more common in PostgreSQL convention.

Should I use TIMESTAMP or TIMESTAMPTZ in PostgreSQL?

Prefer TIMESTAMPTZ (timestamp with time zone) for almost all datetime columns. PostgreSQL stores TIMESTAMPTZ values in UTC and converts to the client session timezone on retrieval. Plain TIMESTAMP (without time zone) stores the literal value with no conversion. Only use it when your application explicitly manages timezone logic itself.

What is the difference between JSON and JSONB in PostgreSQL?

JSON stores the document as-is in text form, preserving whitespace, key order, and duplicate keys. JSONB parses and stores the document in a binary format, which is faster to query and supports GIN indexes for full-document search. Use JSONB unless you specifically need to preserve exact input representation.

How do I store a UUID primary key in PostgreSQL?

PostgreSQL has a native UUID type that stores the value as 16 bytes internally. Use UUID DEFAULT gen_random_uuid() PRIMARY KEY (PostgreSQL 13+) or uuid_generate_v4() with the uuid-ossp extension on older versions. The UUID type is natively indexable and requires no manual conversion, unlike MySQL's CHAR(36) or BINARY(16).

What is the PostgreSQL equivalent of MySQL AUTO_INCREMENT?

PostgreSQL offers two options. The legacy SERIAL pseudo-type creates an implicit sequence. The SQL-standard alternative is GENERATED ALWAYS AS IDENTITY or GENERATED BY DEFAULT AS IDENTITY, available since PostgreSQL 10. Prefer GENERATED ALWAYS AS IDENTITY in new schemas. It's standards-compliant and gives explicit control over sequence behavior.

Does PostgreSQL have a native boolean type?

Yes. PostgreSQL's BOOLEAN type stores true, false, or NULL natively. It accepts multiple input formats: TRUE/FALSE, 't'/'f', 'yes'/'no', 'on'/'off', and 1/0. This is a key difference from MySQL, which has no native boolean and uses TINYINT(1) as a convention.