Filter concepts by levelShowing all levels.

PostgreSQL · Section 6

PostgreSQL Data Types

Level
intermediate
Read
36 min
Concepts
7

The exact-vs-approximate line that actually separates the numeric types, PostgreSQL's distinctive extensions (JSONB, arrays, enums, ranges), why timestamptz and timestamp answer fundamentally different questions, why money usually loses to numeric for real financial schemas, and when a cast happens automatically versus must be written explicitly.

This section

What is true here

  1. integer/bigint/numeric are exact; real/double precision are approximate IEEE 754 floating-point.
  2. text and varchar(n) share identical storage; char(n) pads with trailing spaces.
  3. timestamptz stores one unambiguous real-world instant; timestamp stores bare wall-clock numbers with no timezone context.
  4. money's formatting and rounding depend on the server locale; numeric(p,s) is portable and schema-explicit.
  5. PostgreSQL only allows an implicit cast when the conversion is safe and unambiguous — text to a typed column is never implicit.

What you will be able to do

  • Choose the right numeric type family for exact vs approximate data, and never use float for money
  • Pick between text, varchar and char, and between JSONB/array/enum/range and their relational alternatives
  • Explain why timestamptz is the safer default for real-world events
  • Predict when a cast happens automatically and when it must be written explicitly
The recurring trade-off across this section
yes — the trade-offis worth it

Standard SQL type

integer, numeric, text, timestamp

Does the extra capability pay for itself?

exactness, expressiveness, or portability at stake

PostgreSQL-specific type

JSONB, array, enum, range, timestamptz

  • Standard SQL type — integer, numeric, text, timestamp
    • leads to Does the extra capability pay for itself?
  • Does the extra capability pay for itself? — exactness, expressiveness, or portability at stake
    • leads to PostgreSQL-specific type (yes — the trade-off is worth it)
  • PostgreSQL-specific type — JSONB, array, enum, range, timestamptz

Numbers and text

Exact vs approximate numerics, and why text and varchar are really the same type underneath.

Numeric Types: smallint, integer, bigint, numeric, real, double precision

corebeginner

smallint, integer and bigint are exact whole numbers of increasing range. numeric (aka decimal) is exact with arbitrary precision, ideal for money. real and double precision are approximate floating-point — fast, but not exact, and wrong for money.

Think of it as

The real dividing line is not "small vs large" but "exact vs approximate." integer/bigint/numeric always represent the value written exactly. real/double precision are IEEE 754 floating-point, which cannot represent most decimal fractions exactly — 0.1 stored as a double is not really 0.1. Choosing between them is really choosing whether occasional tiny representation error is acceptable.

sql
CREATE TABLE accounts (
    id BIGINT PRIMARY KEY,
    balance NUMERIC(12, 2) NOT NULL,
    measurement DOUBLE PRECISION
);

What we're doing: Show the exact-vs-approximate difference directly by comparing 0.1 + 0.2 under numeric and double precision.

numeric_vs_float.sqlsql
SELECT (0.1::double precision + 0.2::double precision) AS float_sum,
       (0.1::numeric + 0.2::numeric) AS numeric_sum,
       (0.1::double precision + 0.2::double precision = 0.3) AS float_equals_exact,
       (0.1::numeric + 0.2::numeric = 0.3) AS numeric_equals_exact;
1
double precision arithmetic — IEEE 754 cannot represent 0.1 or 0.2 exactly in binary.
2
numeric arithmetic — stores and computes the exact decimal value, no representation error.
Output
float_sum          | numeric_sum | float_equals_exact | numeric_equals_exact
--------------------+-------------+---------------------+----------------------
0.30000000000000004 |        0.30 | f                   | t

Why this works: double precision stores numbers in binary floating-point, and 0.1 and 0.2 have no exact finite binary representation (the same way 1/3 has no exact finite decimal representation) — the stored values are already tiny approximations before any arithmetic even happens, so their sum is a slightly-off approximation too. numeric stores the exact decimal digits directly rather than converting to binary, so 0.1 + 0.2 in numeric arithmetic is genuinely, exactly 0.3, with no representation error at any step.

Using double precision (or real) to store monetary amounts

Wrong

sql
CREATE TABLE accounts (id SERIAL PRIMARY KEY, balance DOUBLE PRECISION);
UPDATE accounts SET balance = balance + 0.1 WHERE id = 1;
-- repeated additions can accumulate visible rounding error over time

Better

sql
CREATE TABLE accounts (id SERIAL PRIMARY KEY, balance NUMERIC(12, 2));
UPDATE accounts SET balance = balance + 0.1 WHERE id = 1;
-- exact, no accumulated error, ever

What you see: Account balances drift by fractions of a cent after many transactions, or a balance check like WHERE balance = 100.00 fails to match a value that displays as exactly "100.00," because the stored floating-point value is actually 99.99999999999997 or similar.

Why: Every floating-point arithmetic operation can introduce a small representation error, and those errors compound across repeated additions/subtractions in a way that is unpredictable and, over enough transactions, becomes financially significant — this is a property of IEEE 754 itself, not something PostgreSQL can fix. numeric was specifically designed to avoid this entire class of problem by never converting to binary floating-point in the first place, which is why it is the standard, non-negotiable choice for any exact monetary or accounting value.

Exact vs approximate — the real dividing line

Exact: integer / bigint / numeric

  • +represents the written value precisely
  • +numeric arithmetic never introduces rounding error
  • +the correct family for money, counts, IDs

Approximate: real / double precision

  • IEEE 754 floating-point — cannot represent most decimals exactly
  • 0.1 + 0.2 = 0.30000000000000004 in double precision
  • fine for scientific/measurement data, wrong for money
  • Exact: integer / bigint / numeric
    • represents the written value precisely
    • numeric arithmetic never introduces rounding error
    • the correct family for money, counts, IDs
  • Approximate: real / double precision
    • IEEE 754 floating-point — cannot represent most decimals exactly
    • 0.1 + 0.2 = 0.30000000000000004 in double precision
    • fine for scientific/measurement data, wrong for money

The numeric type family at a glance

The numeric type family at a glance
TypeStorageExact?Typical use
smallint2 bytesyessmall counters, enums stored as ints
integer4 bytesyesdefault choice for whole numbers
bigint8 bytesyesIDs expected to exceed 2 billion rows
numeric(p,s)variableyesmoney, anything needing exact decimals
real / double precision4 / 8 bytesno — approximatescientific data where tiny error is acceptable

Together

sql
SELECT 0.1::double precision + 0.2::double precision = 0.3;  -- false
SELECT 0.1::numeric + 0.2::numeric = 0.3;                     -- true

Remember: integer/bigint/numeric are exact; real/double precision are approximate IEEE 754 floating-point that cannot represent most decimals exactly — never use float types for money.

See also: date time types · money vs numeric

Text Types: varchar, char and text

standardbeginner

text stores a string of any length with no limit. varchar(n) is the same storage as text but enforces a maximum length of n. char(n) pads every value with trailing spaces to exactly n characters — almost never what modern schemas want.

Think of it as

Unlike many databases, PostgreSQL gives varchar(n) and text identical internal storage and performance — varchar(n) is really just "text plus a length check," not a separate, more efficient type. char(n) is the outlier: it silently pads short values with spaces, which is a legacy fixed-width behavior most application code does not expect.

sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL,
    country_code VARCHAR(2)
);

What we're doing: Store the same short value as text, varchar(10) and char(10), and see how char pads it while the others do not.

text_types_demo.sqlsql
CREATE TABLE demo (t TEXT, v VARCHAR(10), c CHAR(10));
INSERT INTO demo VALUES ('hi', 'hi', 'hi');

SELECT t, v, c, length(t) AS t_len, length(v) AS v_len, length(c) AS c_len,
       octet_length(c) AS c_bytes
FROM demo;
2
'hi' is inserted identically into all three columns.
4–5
length() reports 2 for all three, since PostgreSQL's length() trims char(n)'s trailing padding — but octet_length shows the padded column actually occupies 10 bytes on disk.
Output
t  | v  | c          | t_len | v_len | c_len | c_bytes
---+----+------------+-------+-------+-------+--------
hi | hi | hi         |     2 |     2 |     2 |     10

Why this works: char(10) physically stores 'hi' followed by 8 trailing spaces, always padding to exactly the declared length regardless of the actual content — but PostgreSQL's length() and string comparison functions are defined to disregard char(n)'s trailing padding, which is why length(c) still reports 2 even though octet_length(c) reveals the true 10-byte storage. text and varchar(10) never pad at all; 'hi' occupies exactly 2 bytes of content in both.

Using char(n) expecting it to behave like a length-limited varchar

Wrong

sql
CREATE TABLE users (username CHAR(20));
INSERT INTO users VALUES ('ada');
-- stored as 'ada' + 17 trailing spaces -- concatenating it elsewhere can produce surprising extra whitespace

Better

sql
CREATE TABLE users (username VARCHAR(20));
INSERT INTO users VALUES ('ada');
-- stored as exactly 'ada', no padding

What you see: String concatenation or external-system output built from a char(n) column contains unexpected extra whitespace, even though comparisons and length() inside PostgreSQL looked correct.

Why: PostgreSQL's own string functions are defined to ignore char(n)'s trailing padding for comparison and length purposes, which hides the padding during normal querying — but the padding is still physically part of the stored value, and any operation that treats the string as raw bytes (concatenation into a fixed-width export, hashing, or an external system with different padding rules) will see it. varchar(n) or text never introduce this discrepancy because they never pad in the first place.

The three text types compared

The three text types compared
TypeLength behaviorPadding
textunlimitednone
varchar(n)capped at n, error if exceedednone
char(n)fixed at npads with trailing spaces

Together

sql
SELECT 'ab'::char(5) = 'ab'::text;  -- true -- trailing spaces ignored in comparison
SELECT length('ab'::char(5));       -- 2 -- length() also ignores the padding

Remember: text and varchar(n) share identical storage in PostgreSQL — the only real difference is a length check. char(n) pads with trailing spaces and is rarely the right choice for a modern schema.

See also: numeric types · uuid json arrays enums and ranges

Advertisement

PostgreSQL's distinctive types

UUID, JSONB, arrays, enums, ranges, and the date/time family's single most consequential choice.

boolean, bytea, UUID, JSON/JSONB, Arrays, Enums and Range Types

standardintermediate

boolean is true/false/NULL. bytea stores raw binary data. UUID stores a 128-bit identifier. JSON/JSONB store semi-structured data (JSONB is binary-parsed and indexable; JSON is text and re-parsed each time). Arrays store a list of one type per column. Enums restrict a column to a fixed set of labels. Range types (like daterange) store a bounded interval as a single value.

Think of it as

Each of these exists to avoid modeling something awkwardly in plain relational columns. UUID avoids a centralized sequence for globally-unique IDs. JSONB avoids a rigid schema for genuinely variable data. Arrays avoid a join for a small, tightly-owned list. Enums avoid a lookup table for a small fixed vocabulary. Range types avoid two separate start/end columns plus manual overlap logic for an interval.

sql
CREATE TYPE mood AS ENUM ('happy', 'sad', 'neutral');

CREATE TABLE events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tags TEXT[],
    metadata JSONB,
    current_mood mood,
    active_period DATERANGE
);

What we're doing: Create a table exercising several of these types at once, then query the array, JSONB and range columns.

extended_types.sqlsql
CREATE TABLE events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tags TEXT[],
    metadata JSONB,
    active_period DATERANGE
);

INSERT INTO events (tags, metadata, active_period)
VALUES (ARRAY['sale', 'summer'], '{"discount": 20}'::jsonb, daterange('2026-06-01', '2026-06-30'));

SELECT 'sale' = ANY(tags) AS has_sale_tag,
       metadata->>'discount' AS discount,
       active_period @> '2026-06-15'::date AS covers_june_15
FROM events;
8
gen_random_uuid() generates the id client-independently, no shared sequence needed.
10
= ANY(tags) checks array membership — the array-equivalent of IN against a subquery.
11
->> extracts a JSONB value as text.
12
@> is the range containment operator — is this date inside the range?
Output
has_sale_tag | discount | covers_june_15
--------------+----------+---------------
t             | 20       | t

Why this works: Each type brings its own operator family suited to its shape: = ANY(array) tests element membership without a join, ->> navigates JSONB without parsing text on every access (JSONB is stored pre-parsed), and @> tests range containment as a single indexable operation instead of two separate comparisons (start <= date AND date <= end) that the planner would have to reason about independently.

Modeling a many-valued relationship as an array when it needs referential integrity

Wrong

sql
CREATE TABLE posts (id SERIAL PRIMARY KEY, tag_ids INTEGER[]);
-- no foreign key constraint possible on individual array elements
-- a deleted tag silently leaves a dangling id in every post's array

Better

sql
CREATE TABLE post_tags (
    post_id INT REFERENCES posts(id),
    tag_id INT REFERENCES tags(id),
    PRIMARY KEY (post_id, tag_id)
);
-- a junction table enforces referential integrity per element

What you see: Deleting a tag leaves stale, invalid ids sitting inside every post's tag_ids array, with no constraint violation and no error — the dangling reference is only discovered later, if at all.

Why: PostgreSQL cannot attach a FOREIGN KEY constraint to individual elements of an array column — the constraint machinery operates on whole column values, not on each element of a list stored inside one. A junction table gives every individual relationship its own row, which is exactly where a foreign key constraint can be enforced per pair, which is why arrays are appropriate for small, loosely-related lists but not for relationships that need real referential integrity.

When each extension type earns its place over a plain relational column

When each extension type earns its place over a plain relational column
TypeReaches for it instead of
UUIDa centralized auto-increment sequence, when IDs must be generated client-side or across systems
JSONBa rigid schema, for genuinely variable or sparse attributes
arraya junction table, for a small list that is never queried by individual element independently
enuma lookup table, for a small, rarely-changing fixed vocabulary
range (daterange, etc.)two separate start/end columns plus manual overlap logic

Together

sql
CREATE TYPE mood AS ENUM ('happy', 'sad', 'neutral');
SELECT daterange('2026-01-01', '2026-01-05') && daterange('2026-01-04', '2026-01-10');  -- true

Remember: UUID, JSONB, arrays, enums and range types each replace a specific relational workaround — reach for them when the workaround (a shared sequence, a rigid schema, a junction table, a lookup table, manual interval logic) is genuinely worse than the specialized type, not by default.

See also: text types · postgresql specific vs portable types

Date/Time Types: date, time, timestamp, timestamptz, interval

corebeginner

date is a calendar date with no time. time is a time of day with no date. timestamp stores a date and time with no timezone attached. timestamptz stores a date and time normalized to UTC internally, converting to the session's timezone on display. interval represents a duration, like "3 days" or "2 hours."

Think of it as

timestamp and timestamptz are not "timestamp without/with an extra timezone field" — timestamptz does not actually store a timezone at all. It converts whatever timezone the input was given in to UTC at write time, stores only that UTC instant, and converts back to the session's timezone only for display. timestamp stores exactly the wall-clock numbers typed in, with no idea what timezone they were meant to represent.

sql
CREATE TABLE events (
    id SERIAL PRIMARY KEY,
    happened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    duration INTERVAL
);

What we're doing: Insert the same literal timestamp value as both timestamp and timestamptz, then change the session timezone and observe what changes.

timestamptz_demo.sqlsql
CREATE TABLE demo (plain TIMESTAMP, tz TIMESTAMPTZ);

SET TIME ZONE 'UTC';
INSERT INTO demo VALUES ('2026-06-01 12:00:00', '2026-06-01 12:00:00');

SELECT plain, tz FROM demo;

SET TIME ZONE 'America/New_York';
SELECT plain, tz FROM demo;
3
Insert happens while the session timezone is UTC — timestamptz interprets the literal as 12:00 UTC and stores that instant.
8–9
Session timezone changes to America/New_York (UTC-4 in June); querying the same stored rows again shows the difference.
Output
plain               | tz
---------------------+------------------------
2026-06-01 12:00:00 | 2026-06-01 12:00:00+00
(1 row)

plain               | tz
---------------------+------------------------
2026-06-01 12:00:00 | 2026-06-01 08:00:00-04
(1 row)

Why this works: plain (timestamp) shows 12:00:00 both times, because it never stored any timezone context — it is simply displaying the same wall-clock numbers it was given, regardless of the session's timezone setting. tz (timestamptz) shows 08:00:00-04 the second time, not because the stored value changed, but because it is displaying the exact same UTC instant (12:00 UTC) converted into the new session timezone (UTC-4) — 12:00 UTC and 08:00 EDT are the identical real-world moment.

Using plain timestamp for an event that needs to represent a real-world instant

Wrong

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, placed_at TIMESTAMP);
-- if the app server's timezone setting ever changes, or servers run in different timezones,
-- "placed_at" values become ambiguous or inconsistent

Better

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, placed_at TIMESTAMPTZ NOT NULL DEFAULT now());
-- always an unambiguous instant, correctly comparable across any timezone

What you see: Two orders that happened at genuinely different real-world moments compare as equal, or sort incorrectly, once the application or database server's timezone configuration changes — or worse, once the system spans multiple servers in different timezones.

Why: A plain timestamp column has no way to know what timezone its stored numbers were meant to represent, so comparing or sorting values entered under different implicit timezone assumptions produces meaningless results — the database cannot detect or correct this because the ambiguity is baked into the type itself. timestamptz eliminates the ambiguity structurally: every value it stores is genuinely comparable to every other, because they are all normalized to the same UTC reference internally.

timestamp vs timestamptz under a changed session timezone

timestamp

  • +stores exactly the wall-clock numbers written
  • +no idea what timezone those numbers meant
  • +displays identically regardless of session timezone

timestamptz

  • converts input to UTC at write time
  • stores one unambiguous real-world instant
  • displays differently per session timezone — same instant, different wall-clock text
  • timestamp
    • stores exactly the wall-clock numbers written
    • no idea what timezone those numbers meant
    • displays identically regardless of session timezone
  • timestamptz
    • converts input to UTC at write time
    • stores one unambiguous real-world instant
    • displays differently per session timezone — same instant, different wall-clock text

The five date/time types

The five date/time types
TypeStoresTimezone-aware?
datea calendar date onlyno
timea time of day onlyno (time with time zone exists but is rarely used)
timestampdate + time, wall-clockno
timestamptzdate + time, normalized to UTC internallyyes
intervala durationnot applicable

Together

sql
SET TIME ZONE 'UTC';
SELECT '2026-06-01 12:00'::timestamptz;
SET TIME ZONE 'America/New_York';
SELECT '2026-06-01 12:00'::timestamptz;  -- same instant, different displayed wall-clock time

Remember: timestamptz stores one unambiguous real-world instant (converted to UTC internally); timestamp stores bare wall-clock numbers with no timezone context — default to timestamptz for anything representing a real event.

See also: uuid json arrays enums and ranges · implicit vs explicit casts

Advertisement

Judgment calls

money vs numeric, when a cast happens for free, and portability trade-offs.

Why numeric Is Usually Preferred Over money

standardintermediate

PostgreSQL has a money type — it stores an exact fixed-fraction currency amount and formats it with a currency symbol automatically. In practice, most schemas use numeric(p,s) instead, because money has no configurable currency, rounds silently on assignment, and is locale-dependent in ways that surprise most applications.

Think of it as

money looks like the obvious type for currency by name alone, but it was designed around a single implicit locale-driven currency format, not multi-currency, auditable financial systems. numeric with an application-defined precision and a separate currency-code column gives explicit, portable control over exactly what most real financial schemas actually need — money bundles formatting and precision decisions the application usually wants to own itself.

sql
-- typical modern schema: numeric + explicit currency code
CREATE TABLE invoices (
    id SERIAL PRIMARY KEY,
    amount NUMERIC(12, 2) NOT NULL,
    currency_code CHAR(3) NOT NULL DEFAULT 'USD'
);

What we're doing: Show money silently rounding a value to its locale's fractional digits, where numeric preserves the exact value entered.

money_vs_numeric.sqlsql
SET lc_monetary = 'en_US.UTF-8';

SELECT 19.999::money AS as_money,
       19.999::numeric(10, 3) AS as_numeric;
3
money rounds to whatever fractional precision the active locale specifies for currency (2 decimal places for en_US).
4
numeric(10, 3) keeps exactly the precision the schema explicitly declared — no locale involvement.
Output
as_money | as_numeric
---------+-----------
$20.00   |     19.999

Why this works: money always formats and rounds according to lc_monetary, which is a session/server locale setting rather than a per-column schema decision — so the same literal value produces different stored precision depending on server configuration that has nothing to do with the application's actual business rules. numeric(10, 3) is entirely explicit: the schema itself declares "3 decimal places," independent of any locale setting, which is portable across servers, environments, and PostgreSQL versions in a way money's locale dependency is not.

Choosing money because the name matches the use case

Wrong

sql
CREATE TABLE invoices (id SERIAL PRIMARY KEY, amount MONEY);
-- no currency column -- and precision/formatting silently depends on server locale

Better

sql
CREATE TABLE invoices (
    id SERIAL PRIMARY KEY,
    amount NUMERIC(12, 2) NOT NULL,
    currency_code CHAR(3) NOT NULL DEFAULT 'USD'
);

What you see: A financial report computed correctly in one environment produces subtly different rounding in another (staging vs production, or after a server migration), traced eventually to a difference in the lc_monetary locale setting rather than any application code change.

Why: money's formatting and fractional-digit behavior is tied to a locale setting that lives outside the schema and outside version control — nothing in the table definition documents or fixes it, so two servers with different locale configurations can legitimately behave differently on the exact same data. numeric(p,s) makes the precision decision part of the schema itself, visible in the CREATE TABLE statement and identical everywhere that schema is deployed.

money vs numeric for currency storage

money vs numeric for currency storage
Propertymoneynumeric(p,s)
Exact arithmeticyesyes
Currency-awareno — one implicit locale currencyno, but pairs naturally with a currency_code column
Formattinglocale-dependent, automaticapplication controls formatting explicitly
Portability across environmentsdepends on lc_monetaryfully portable — locale-independent

Together

sql
SET lc_monetary = 'en_US.UTF-8';
SELECT 19.999::money;   -- $20.00 -- silently rounded to 2 decimal places

Remember: money's formatting and rounding depend on the server's locale setting, which lives outside the schema — numeric(p,s) with an explicit currency_code column gives portable, schema-defined precision instead.

See also: numeric types · implicit vs explicit casts

Implicit vs Explicit Casts and Type-Conversion Behavior

standardintermediate

An implicit cast happens automatically when PostgreSQL can convert one type to another without ambiguity, like integer to numeric. An explicit cast, written as value::type or CAST(value AS type), is required when the conversion is ambiguous or lossy, like text to integer.

Think of it as

PostgreSQL only allows an implicit cast when the conversion is safe and unambiguous — nothing is silently guessed. text to integer is never implicit, because '1a' is not a valid integer and PostgreSQL will not silently decide what to do with it; the conversion must be requested explicitly, at which point it either succeeds or raises a clear error. This is a deliberate safety property, not a limitation.

sql
SELECT '42'::integer;
SELECT CAST('42' AS integer);   -- equivalent, standard SQL form

SELECT price::text FROM products;   -- explicit, numeric to text

What we're doing: Show text-to-integer requiring an explicit cast, and a WHERE clause comparing a text literal against an integer column to see how PostgreSQL resolves it.

cast_demo.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INTEGER);
INSERT INTO orders (customer_id) VALUES (42);

SELECT '42'::integer + 1;
-- 43

SELECT * FROM orders WHERE customer_id = '42';
-- works: '42' is implicitly cast to integer here, since = requires matching operand types
4
An explicit cast is required to turn text '42' into an integer before + can apply.
7
PostgreSQL resolves customer_id = '42' by casting the text literal '42' to integer to match customer_id's type — legal because integer has an assignment cast from text literals in this context, unlike arbitrary text-to-integer arithmetic.
Output
?column?
--------
      43
(1 row)

id | customer_id
---+------------
 1 |          42
(1 row)

Why this works: PostgreSQL resolves an unknown-type string literal like '42' against the type it is being compared to, which is why customer_id = '42' works without an explicit ::integer — the literal has no fixed type yet at parse time, and the comparison operator resolution picks integer to match customer_id. This is different from casting an already-typed text value or column, which always requires an explicit cast, and it is why the analogous case with a text column instead of a literal behaves very differently (see the mistake below).

Comparing an already-text column against an integer, relying on an implicit cast that does not apply the same way

Wrong

sql
CREATE TABLE imports (external_id TEXT);
INSERT INTO imports VALUES ('42');
SELECT * FROM imports i JOIN orders o ON i.external_id = o.customer_id;
-- may work via an implicit cast, but can silently prevent index use on external_id

Better

sql
SELECT * FROM imports i JOIN orders o ON i.external_id::integer = o.customer_id;
-- explicit, and makes the type mismatch visible to any reader

What you see: A join or WHERE clause between a text column and an integer column runs correctly but performs far worse than expected, because an implicit conversion applied to the indexed column prevents the planner from using that index.

Why: When a comparison forces a cast on a column (rather than on a literal, which PostgreSQL can often resolve before the comparison), the planner may need to evaluate that cast for every row before it can compare values, which typically defeats a plain B-tree index on the original column — the index is built on the stored, uncast values. Writing the cast explicitly does not fix the performance issue by itself, but it makes the mismatch visible, which is the first step toward fixing the actual schema-level type inconsistency (matching the two columns' types) rather than relying on implicit conversion to paper over it.

Implicit vs explicit — a few common conversions

Implicit vs explicit — a few common conversions
From → ToImplicit or explicit?
integer → numericimplicit
numeric → double precisionimplicit
text → integerexplicit only — '123'::integer
timestamp → dateimplicit (truncates the time)
integer → textexplicit only — 123::text

Together

sql
SELECT '123'::integer + 1;   -- explicit cast required, then works
SELECT '123' + 1;             -- ERROR: operator does not exist

Remember: PostgreSQL only allows an implicit cast when the conversion is safe and unambiguous — text to a typed column is never implicit for an already-typed value, only for an untyped literal being resolved against its comparison target.

See also: date time types · postgresql specific vs portable types

PostgreSQL-Specific Types vs Portable SQL Types

standardintermediate

Portable types (integer, numeric, varchar, timestamp) work the same way, or close to it, across most relational databases. PostgreSQL-specific types (JSONB, arrays, enums, ranges, UUID generation functions) are more expressive and often more efficient in PostgreSQL, but migrating away from PostgreSQL later means rewriting anything built on them.

Think of it as

This is a lock-in trade-off, not a correctness one — PostgreSQL-specific types are not "wrong," they are a bet that the expressiveness and performance gained is worth the cost if the system ever needs to run on a different database engine. Teams with no realistic multi-database future usually take that bet freely; teams building something explicitly meant to be portable (a library, a product sold to run on customer-chosen databases) weigh it much more carefully.

sql
-- PostgreSQL-specific, expressive
CREATE TABLE events (id UUID DEFAULT gen_random_uuid(), tags TEXT[], data JSONB);

-- portable equivalent, more verbose
CREATE TABLE events (id VARCHAR(36), data TEXT);
CREATE TABLE event_tags (event_id VARCHAR(36) REFERENCES events(id), tag VARCHAR(50));

What we're doing: Compare a JSONB-based query against the equivalent logic expressed with only portable types, to see the practical difference in expressiveness.

portability_tradeoff.sqlsql
CREATE TABLE events (id SERIAL PRIMARY KEY, data JSONB);
INSERT INTO events (data) VALUES ('{"type": "click", "target": "button"}');

SELECT * FROM events WHERE data @> '{"type": "click"}';
-- one indexable, native containment query

-- portable equivalent: a TEXT column and LIKE, or a fully normalized schema
CREATE TABLE events_portable (id SERIAL PRIMARY KEY, event_type VARCHAR(50), target VARCHAR(50));
SELECT * FROM events_portable WHERE event_type = 'click';
4
@> is a native JSONB containment operator, GIN-indexable, that most other engines have no direct equivalent for.
7–8
The portable version requires deciding every JSON key's shape upfront as real columns — more rigid, but runs unmodified on nearly any relational database.
Output
id | data
---+----------------------------------------
 1 | {"type": "click", "target": "button"}
(1 row)

Why this works: JSONB's containment query expresses "does this document include these key-value pairs" directly and can use a GIN index to answer it efficiently even against millions of rows with varying, unpredictable shapes — the portable version has to commit to a fixed schema ahead of time, which is more rigid but does not depend on any PostgreSQL-specific feature to run. Neither approach is universally correct; the JSONB version is the better engineering choice specifically when the data's shape is genuinely variable and PostgreSQL is not expected to change.

Committing to PostgreSQL-specific types in a codebase explicitly designed to be database-agnostic

Wrong

sql
-- inside an ORM-agnostic library meant to support multiple database backends
CREATE TABLE plugin_config (id SERIAL PRIMARY KEY, settings JSONB, tags TEXT[]);
-- JSONB and array types have no equivalent on some other targeted backends

Better

sql
CREATE TABLE plugin_config (id SERIAL PRIMARY KEY, settings TEXT);
-- a portable TEXT column holding a JSON string, parsed in application code

What you see: A library or product that advertises support for multiple database backends breaks, or silently loses functionality, the moment a user configures a non-PostgreSQL backend — because a core table relies on a PostgreSQL-only type with no equivalent elsewhere.

Why: PostgreSQL-specific types are a deliberate trade of portability for expressiveness and performance — a fine trade for a system that commits to PostgreSQL, but a direct contradiction of an explicit multi-database design goal. The fix is not "avoid all convenient types forever," it is matching the type choice to whether portability was actually promised, which is a project-level decision worth making explicitly rather than defaulting into either direction.

Portable vs PostgreSQL-specific — a few common choices

Portable vs PostgreSQL-specific — a few common choices
NeedPortable optionPostgreSQL-specific option
Semi-structured dataa TEXT column holding a JSON stringJSONB with indexing/querying
Fixed vocabularya lookup table + foreign keya native ENUM type
A small lista junction tablean array column
A bounded intervaltwo separate start/end columnsa range type (daterange, etc.)

Together

sql
-- portable
CREATE TABLE settings (id SERIAL PRIMARY KEY, data TEXT);
-- PostgreSQL-specific, more capable
CREATE TABLE settings (id SERIAL PRIMARY KEY, data JSONB);

Remember: PostgreSQL-specific types trade portability for expressiveness and performance — a fine default when there is no realistic multi-database future, and a deliberate risk to weigh explicitly when there is one.

See also: uuid json arrays enums and ranges · implicit vs explicit casts

Advertisement