PostgreSQL Data Types

Work with PostgreSQL data types: from numeric to JSON, choose storage wisely, and steer clear of common traps.

Focus: work with postgresql data types

Sponsored

Ever created a column that seemed perfect and then watched your queries crawl, or found yourself casting and recasting values just to get basic operations to work? If you have, you’ve felt the pain that comes from picking the wrong PostgreSQL data type. The type you choose isn’t just a storage detail — it silently dictates your query performance, your data integrity, and even the features you can use later. In this lesson, you’ll learn how to work with PostgreSQL data types like a pro: how to pick the right type for the job, use the specialized ones effectively, and dodge the classic traps that trip up nearly every developer.

The problem this lesson solves

Choosing a data type in PostgreSQL feels like a trivial decision, but it’s the root of many production headaches. Here’s what happens when you ignore types:

  • Silent corruption: Store a date as TEXT and you lose the ability to do date math, compare ranges, or even order correctly (lexicographic order isn’t chronological).
  • Wasted space: A VARCHAR(255) for a single character wastes bytes and cache, slowing every table scan.
  • Performance cliffs: Using TEXT for an IP address forces string comparisons instead of the blazing-fast indexed integer lookups that INET provides.
  • Feature lockout: Need full-text search? That’s only available on certain types. Need JSON features? Only JSONB, not plain JSON, lets you index and query efficiently.
  • Hidden bugs: Storing currency as FLOAT leads to rounding errors that surface months later in reports.

The real problem is that PostgreSQL offers a huge palette of types, and guessing wrong is costly. You need a mental model for when to use each category, and you need it now — before your schema hardens into legacy code.

Core concept / mental model

Think of a data type as a contract between your application and the database. It defines three things:

  1. Storage: how many bytes are used, and whether it’s fixed or variable length.
  2. Operations: which operators and functions can be applied (e.g., + works on numbers, but not on JSONB).
  3. Constraints: what values are allowed (e.g., a DATE can’t hold 'hello', and INTEGER has a max of 2,147,483,647).

PostgreSQL’s type system is rich but logical. Almost all types fall into a few families:

  • Numeric: INTEGER, BIGINT, NUMERIC, REAL, DOUBLE PRECISION.
  • Character: TEXT, VARCHAR(n), CHAR(n).
  • Temporal: DATE, TIME, TIMESTAMP, INTERVAL.
  • Binary & other: BYTEA, UUID, BOOLEAN.
  • Network: INET, CIDR, MACADDR.
  • JSON & arrays: JSON, JSONB, ARRAY.

A diagram in words: imagine each type as a different-shaped container. A TEXT is an elastic bag — it holds any amount of characters but adds no structure. A TIMESTAMP is a clear glass jar with a label for date and time — you can’t put random letters in it without breaking the glass. A JSONB is a smart box that keeps its contents sorted but loses the original whitespace. Your job is to pick the box that fits the data and the operations you need.

The golden rule: pick the smallest type that fulfills all your query and integrity requirements. Smaller types mean faster scans, smaller indexes, and better cache usage.

How it works step by step

When you work with PostgreSQL data types, you’re following a decision pipeline. Here’s the step-by-step process:

  1. Analyze your data: What is the semantic meaning? Is it a number, a date, an IP, a list, a document?
  2. Identify operations: Do you need to sort, filter, compare, index, or do math on it? This drives the type choice.
  3. Pick the family: Numeric, character, temporal, etc.
  4. Pick the exact type: Within the family, choose the precision/size that balances range and storage.
  5. Consider indexes: Some types support specialized indexes (GIN for JSONB, INET for networks). Choose types that unlock those features.
  6. Implement and test: Create the table, insert sample data, and run explain analyze on your typical queries.
  7. Document your choices: Comment the schema so future developers know why you picked what you did.

Cause and effect: if you pick a type that’s too small, you’ll hit overflow errors. Too large, you waste space. Pick the right one and everything just works.

Hands-on walkthrough

Let’s put this into practice. We’ll create a table that uses a variety of types and query them.

Setup: a sample table

CREATE TABLE user_profiles (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    bio TEXT,
    birth_date DATE,
    last_login TIMESTAMP WITH TIME ZONE,
    ip_address INET,
    preferences JSONB,
    tags TEXT[]
);

This table uses a spanning spread: VARCHAR for a bounded username string, TEXT for an unbounded bio, DATE for a calendar date, TIMESTAMP for a moment in time, INET for network addresses, JSONB for flexible config, and TEXT[] for an array of tags.

Inserting data

INSERT INTO user_profiles 
(username, bio, birth_date, last_login, ip_address, preferences, tags)
VALUES 
('alice', 'Loves hiking', '1990-04-12', '2025-03-01 08:30:00+00', '192.168.1.1', '{"theme": "dark"}', ARRAY['sql', 'postgres']),
('bob', 'NULL bio', NULL, NULL, NULL, NULL, NULL);

Notice that we didn’t need to cast — PostgreSQL implicitly converts the literal strings to the declared types. But we could also use explicit casts: '1990-04-12'::DATE.

Querying with type awareness

Now, as a developer you’ll often need to manipulate these types. Here’s a query that uses the types for business logic:

SELECT 
    username,
    age(birth_date) AS age_interval,
    extract(YEAR FROM last_login) AS last_login_year,
    ip_address << '192.168.0.0/16'::inet AS is_private_network,
    preferences->>'theme' AS theme,
    array_length(tags, 1) AS tag_count
FROM user_profiles;

Expected output:

 username |    age_interval    | last_login_year | is_private_network | theme | tag_count
----------+--------------------+-----------------+--------------------+-------+-----------
 alice    | 34 years 10 mons   |            2025 | t                  | dark  |         2
 bob      |                    |                 | f                  |       | 

See how each type enables a specific operation? DATE lets you compute age, TIMESTAMP allows year extraction, INET supports subnet checks, JSONB allows ->> to pull a key, and TEXT[] supports array_length.

Querying network types with index

Let’s show how types affex indexing. INET columns can use the inet_gist_ops operator class:

CREATE INDEX idx_profiles_ip ON user_profiles USING gist (ip_address inet_ops);

Now a query like WHERE ip_address << '10.0.0.0/8'::inet will use this index.

Using JSONB for flexible search

CREATE INDEX idx_profiles_prefs ON user_profiles USING gin (preferences jsonb_path_ops);
SELECT username, preferences->>'theme' AS theme 
FROM user_profiles 
WHERE preferences @> '{"theme": "dark"}';

This is how you use the @> containment operator — it works only with JSONB, not JSON.

Compare options / when to choose what

Now you know the mechanics — but how do you decide in practice? Here’s a comparison table for the most confusing decisions:

Decision Type A (When to use) Type B (When to use) Why
Character string VARCHAR(n) when the value has a known max length (e.g., username ≤ 50) TEXT when there is no real limit (e.g., bio) VARCHAR(n) enforces length, TEXT doesn’t waste a check; both perform the same in PostgreSQL — they’re TOAST-able and nearly identical in storage.
Number INTEGER for IDs/ages (up to 2B) BIGINT for large IDs, NUMERIC for exact decimals INTEGER is 4 bytes, BIGINT 8; NUMERIC is variable length and slower but exact.
Date & time DATE for calendar dates only TIMESTAMP for moments in time (with or without TZ) Use DATE for birthdays, TIMESTAMP for events and last-login.
IP address INET for both IPv4 and IPv6 CIDR for network blocks INET allows host and subnet queries; CIDR is for representing subnets.
JSON storage JSONB (binary) JSON (plain text) JSONB supports indexing and fast containment; always choose JSONB for performance.
Boolean BOOLEAN with three-state or NOT NULL SMALLINT (0/1) — legacy PostgreSQL BOOLEAN is clear and uses 1 byte; avoid SMALLINT unless interfacing with old code.

When to choose what: The best practice is to use TEXT for almost all character data (unless you need a length constraint for business rules), BIGSERIAL for primary keys (though UUID is better for distributed systems), NUMERIC for any money, and JSONB for any JSON you intend to query.

Variations worth knowing

  • Range types: like TSRANGE for time periods, NUMRANGE for numeric ranges — they let you query overlaps directly.
  • User-defined types: you can create CREATE TYPE for custom composites — useful for structured data that repeats.
  • Arrays vs. relational tables: PostgreSQL supports arrays, but avoid them for queryable data — normalizing is usually better.

These are alternatives to the standard types when your data has special structure.

Troubleshooting & edge cases

Even with the right mental model, you’ll hit traps. Here are the most common ones:

1. Date parsing failure

Just because a string looks like a date doesn’t mean PostgreSQL knows it. If you pass '03/04/2024', it might be ambiguous (March 4 vs. April 3).

-- Fails on '03/04/2024' if DateStyle is MDY
SELECT '03/04/2024'::date;

Fix: Always use SET datestyle TO 'ISO, DMY' or use to_date('03/04/2024', 'MM/DD/YYYY') for explicit parsing.

2. Numeric overflow

SELECT 2147483647 + 1::int; throws an overflow error. Use BIGINT or NUMERIC.

3. Timezone confusion

TIMESTAMP WITHOUT TIME ZONE doesn’t adjust for TimeZone; WITH TIME ZONE does. Mixing them leads to surprising offsets.

Fix: Be consistent. Store all times in UTC using TIMESTAMP WITH TIME ZONE.

4. JSONB loses whitespace and key order

If you store '{"a": 1, "b": 2}' as JSONB, it will be normalized to {"a": 1, "b": 2} (keys sorted). That’s fine if you don’t care about the original format, but if you do, use JSON.

5. VARCHAR(n) doesn’t actually help performance

Many devs think VARCHAR(255) limits disk I/O — it doesn’t. The char length limit is a constraint, not a size plan. Use TEXT unless you need a constraint.

6. Implicit casts in comparisons

SELECT * FROM t WHERE numeric_col = 1.0; might not match if numeric_col is SMALLINT because 1.0 is NUMERIC: use = 1 instead.

What you learned & what's next

You’ve now got a working grasp of work with postgreSQL data types. You learned:

  • What the core idea is: pick a type based on the data semantics, operations, and storage requirements.
  • That types enable specific operations: INET for network checks, JSONB for flexible querying, DATE for date arithmetic.
  • How to apply those types in a hands-on exercise: you built a user_profiles table and queried it with type-specific functions.
  • How to compare options: you now know when to use TEXT vs VARCHAR, JSONB vs JSON, and INET vs CIDR.
  • How to troubleshoot: you can fix date parsing, overflow, timezone mixes, JSONB normalization, and cast pitfalls.

Next in the track, you’ll learn how to design efficient indexes on these types — turning your schema into a high-performance foundation. You’ll explore which index types work best with each data type, and how to analyze query plans to prove your choices.

Go ahead and apply what you learned to your own schema — and then move on to the next lesson.

Practice recap

Create a table for a library catalog — include columns for title (TEXT), ISBN (VARCHAR or BIGINT), published_date (DATE), price (NUMERIC), formats (TEXT[]), and metadata (JSONB). Insert a few rows, then write queries that use date extraction and JSONB containment. Try indexing the JSONB column with GIN and see how the plan changes.

Common mistakes

  • Using VARCHAR(n) for performance — it doesn’t: PostgreSQL stores any varchar the same as text, and the length limit is just a constraint. Use TEXT unless you need a business rule for max length.
  • Storing date and time as TEXT — this kills date arithmetic, ordering, and index efficiency. Always use DATE/TIMESTAMP.
  • Using JSON instead of JSONB when you need querying — JSON is plain text with no indexing; always use JSONB unless you must preserve whitespace/key order.
  • Mixing TIMESTAMP WITH and WITHOUT TIME ZONE across columns — leads to off-by-hours bugs. Stick to one convention (prefer WITH TIME ZONE and UTC).

Variations

  1. User-defined composite types (CREATE TYPE) for structured multidomain values like points or addresses.
  2. Range types (TSRANGE, NUMRANGE) to represent and query intervals directly.
  3. Arrays for simple multi-valued data, though normalization is usually preferable if you need to query individual elements.

Real-world use cases

  • Schema design for an e-commerce platform: choose NUMERIC for prices and INET for customer IPs to enable exact math and network range blocking.
  • Event tracking pipeline: use TIMESTAMP WITH TIME ZONE for event occurrence times to support global, timezone-aware analytics.
  • Content management system storing flexible page metadata: use JSONB for fast filtering and indexing of custom fields.

Key takeaways

  • Data types are contracts that define storage, operations, and integrity — choose them deliberately.
  • Use TEXT for unbounded strings, VARCHAR(n) only when a length constraint is a business rule.
  • Use NUMERIC for exact decimals (like money) and BIGINT or INTEGER for whole numbers.
  • Use TIMESTAMP WITH TIME ZONE for all moments in time to avoid timezone bugs.
  • Use JSONB for any JSON you need to query — it supports GIN indexes and type-specific operators.
  • Use INET for IP addresses to leverage subnet-aware indexing and operators.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.