PostgreSQL

PostgreSQL Data Types — Choosing the Right One

Thirdy Gayares
14 min read

🎯 What You Will Learn

Every column you create asks one question: what kind of data lives here? Answer it wrong and the mistake follows you for years — money that doesn't add up, timestamps that shift when you deploy to a new server, IDs that overflow on a Friday night. This guide teaches you to answer it right the first time.

  • Why the column type is your first layer of data validation
  • Exact vs approximate numbers — and why money is always NUMERIC
  • The TEXT vs VARCHAR(n) vs CHAR(n) truth (spoiler: TEXT is fine)
  • TIMESTAMPTZ vs TIMESTAMP — what timezone-aware actually stores
  • UUID, arrays, and JSONB — the power types and when they're right
  • Constrained values: CHECK vs ENUM, casting with ::, and how NULL really behaves
  • A "you're storing X → use Y" decision table you can bookmark

Prerequisites: a running PostgreSQL and psql — see Install PostgreSQL. This post pairs with Create Database & Tables — the schema we practice on here is the same little web shop used across the whole PostgreSQL series.

Why Types Matter (The Type IS Your First Validation)

Before any application code runs — before Pydantic, before Zod, before your API even exists — PostgreSQL checks every value against the column's type. An INTEGER column will never hold "hello". A NUMERIC(10,2) price will never silently grow a 15th decimal. The type is a contract that the database enforces on every writer: your app, a teammate's script, a 3 AM manual fix in psql. Ang importante dito: the type is validation that cannot be bypassed.

And a wrong type haunts you. The classic horror story: someone stores money in FLOAT because "it's a number with decimals, right?". Months later, refund totals are off by a centavo, finance opens a ticket, and you learn the hard way that floats are approximate. See it yourself — this is one line of SQL:

float_horror.sql
SELECT 0.1::float + 0.2::float AS float_sum;
      float_sum
---------------------
 0.30000000000000004
(1 row)

That is not a PostgreSQL bug — it's how binary floating point works in every language. Now the same math with NUMERIC, PostgreSQL's exact decimal type:

numeric_fix.sql
SELECT 0.1::numeric + 0.2::numeric AS numeric_sum;
 numeric_sum
-------------
         0.3
(1 row)
💡
Mental model: binary floats can't represent 0.1 exactly, the same way decimal can't represent 1/3 exactly — you get 0.3333… and cut it off somewhere. FLOAT stores the nearest binary fraction, and tiny errors compound with every operation. NUMERIC stores real decimal digits, so 0.1 + 0.2 is exactly 0.3. Exact types for exact data, approximate types for measurements.

Let's set up a playground so every example in this post is runnable. This is the compact version of the web-shop schema from Create Database & Tables:

1Create the tables
01_setup_schema.sql
CREATE TABLE customers (
  customer_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  full_name   TEXT NOT NULL,
  email       TEXT NOT NULL UNIQUE,
  city        TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE products (
  product_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name       TEXT NOT NULL,
  category   TEXT NOT NULL,
  price      NUMERIC(10,2) NOT NULL CHECK (price >= 0),
  stock      INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE orders (
  order_id    INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers (customer_id),
  status      TEXT NOT NULL DEFAULT 'pending',
  ordered_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
2Seed a few rows
02_seed_data.sql
INSERT INTO customers (full_name, email, city) VALUES
  ('Maria Santos',   '[email protected]', 'Manila'),
  ('Juan dela Cruz', '[email protected]',  'Cebu'),
  ('Ana Reyes',      '[email protected]',   NULL);

INSERT INTO products (name, category, price, stock) VALUES
  ('Mechanical Keyboard', 'peripherals', 3499.00, 12),
  ('USB-C Hub',           'accessories', 1299.00, 30),
  ('27-inch Monitor',     'displays',    9990.00, 8);

INSERT INTO orders (customer_id, status) VALUES
  (1, 'pending'),
  (2, 'paid');
Checkpoint: run SELECT count(*) FROM products; — you should see 3. Every ALTER TABLE and query below builds on these three tables, and we'll add columns to them as we meet each type.

Numbers: SMALLINT, INTEGER, BIGINT, NUMERIC, FLOAT

PostgreSQL gives you three integer sizes and two families of decimals. First, the integers — the difference is just how many bytes they use and how far they count:

TypeSizeRangeTypical use
SMALLINT2 bytes-32,768 to +32,767Tiny counts: quantity per order line, age, rating 1–5
INTEGER4 bytes-2,147,483,648 to +2,147,483,647The default: stock, IDs for most tables
BIGINT8 bytes±9,223,372,036,854,775,807Big counters: page views, event logs, IDs at massive scale

Pick a size that fits — but know what happens when it doesn't. Integers don't wrap around or silently truncate in PostgreSQL; they fail loudly, which is exactly what you want:

overflow_demo.sql
SELECT 32767::smallint + 1::smallint AS boom;
ERROR:  smallint out of range

That error is your friend — it's a wrong number that never reached your data. If a counter might outgrow INTEGER (2.1 billion sounds like a lot until you log every click), start with BIGINT; changing later on a huge table means a slow full-table rewrite.

1NUMERIC(p, s) — exact decimal math for money

NUMERIC(precision, scale) stores exact decimal digits. Precision is total digits, scale is digits after the decimal point. Our price NUMERIC(10,2) holds up to 99,999,999.99 — 8 digits before the point, 2 after. Values with extra decimals get rounded to the scale, not rejected:

numeric_rounding.sql
SELECT 19.999::numeric(10,2) AS rounded_to_scale;
 rounded_to_scale
------------------
            20.00
(1 row)

But blow past the precision and PostgreSQL refuses — again, loudly:

numeric_overflow.sql
SELECT 1234567890.12::numeric(10,2) AS too_big;
ERROR:  numeric field overflow
DETAIL:  A field with precision 10, scale 2 must round to an absolute value less than 10^8.
2NUMERIC vs FLOAT, side by side

Here's the money bug in aggregate form. Sum ten payments of ₱0.10 — a totally normal thing to do in an orders report:

numeric_vs_float.sql
SELECT SUM(0.10::float8)  AS float_total,
       SUM(0.10::numeric) AS exact_total
FROM generate_series(1, 10);
    float_total     | exact_total
--------------------+-------------
 0.9999999999999999 |        1.00
(1 row)

Ten dimes should be exactly one peso. FLOAT loses a quintillionth — invisible on one row, real money across a million rows, and enough to make WHERE total = 1.00 mysteriously match nothing.

⚠️
Never store money in FLOAT or DOUBLE PRECISION. Use NUMERIC(10,2) (or wider) for prices, totals, salaries, tax — anything where 0.01 must mean exactly one centavo. This is the single most common schema mistake we see in beginner projects, and it's the hardest to clean up because the stored values are already slightly wrong.

So when is FLOAT / DOUBLE PRECISION genuinely fine? When the data is a measurement that was never exact to begin with: sensor readings, scientific values, ML feature vectors, physics simulations. Floats are smaller and faster for math-heavy workloads, and a temperature of 31.400000000000002°C hurts nobody. Exact data → NUMERIC. Approximate data → FLOAT. That one sentence settles 95% of number-type decisions.

Text: TEXT vs VARCHAR(n) vs CHAR(n)

Here's the truth that surprises people coming from other databases: in PostgreSQL, TEXT and VARCHAR(n) are stored identically and perform identically. There is no speed bonus for declaring a length. The only difference is that VARCHAR(n) adds a length check. So the house rule across this whole series: use TEXT unless a maximum length is a real business rule.

💡
Where did VARCHAR(255) come from? It's a cargo cult inherited from old MySQL, where 255 was the largest length that fit a 1-byte prefix. PostgreSQL never had that limitation. If you find yourself typing VARCHAR(255) "because that's what columns look like," you want TEXT.

When a length limit is the point, VARCHAR(n) enforces it at the database level. Say our warehouse scanner can only print 8-character SKUs:

03_add_sku.sql
ALTER TABLE products ADD COLUMN sku VARCHAR(8);

UPDATE products SET sku = 'KB-87-BLK' WHERE product_id = 1;
ALTER TABLE
ERROR:  value too long for type character varying(8)

'KB-87-BLK' is 9 characters — rejected before it could corrupt a label. Trim it and the update goes through:

04_fix_sku.sql
UPDATE products SET sku = 'KB87-BLK' WHERE product_id = 1;
UPDATE products SET sku = 'HUB-USBC' WHERE product_id = 2;
UPDATE products SET sku = 'MON-27IN' WHERE product_id = 3;

SELECT product_id, name, sku FROM products ORDER BY product_id;
 product_id |        name         |   sku
------------+---------------------+----------
          1 | Mechanical Keyboard | KB87-BLK
          2 | USB-C Hub           | HUB-USBC
          3 | 27-inch Monitor     | MON-27IN
(3 rows)

And CHAR(n)? It pads every value with spaces to exactly n characters — a relic of fixed-width mainframe records. The padding behaves in genuinely confusing ways:

char_gotcha.sql
SELECT 'PH'::char(5)                AS code,
       octet_length('PH'::char(5)) AS stored_bytes,
       'PH'::char(5) || '!'        AS concatenated;
 code  | stored_bytes | concatenated
-------+--------------+--------------
 PH    |            5 | PH!
(1 row)
⚠️
The CHAR padding gotcha: 'PH' is stored as 'PH   ' (5 bytes), but comparisons ignore the trailing spaces and concatenation silently strips them — so the padding costs storage while being invisible right up until it isn't (e.g., when an app compares the raw value). For fixed-length codes, skip CHAR entirely and use TEXT with a check: country_code TEXT CHECK (char_length(country_code) = 2).

BOOLEAN: Small Type, Strong Opinions

BOOLEAN stores true or false in a single byte. PostgreSQL is generous about what it accepts as input — all of these are valid true spellings:

boolean_literals.sql
SELECT true           AS kw,
       't'::boolean   AS letter,
       'yes'::boolean AS word,
       '1'::boolean   AS digit,
       'off'::boolean AS this_one_is_false;
 kw | letter | word | digit | this_one_is_false
----+--------+------+-------+-------------------
 t  | t      | t    | t     | f
(1 row)

Notice the output: psql always displays booleans as t and f, no matter which literal you typed in. Let's give products a feature flag the way we'd actually ship it:

05_add_is_featured.sql
ALTER TABLE products
  ADD COLUMN is_featured BOOLEAN NOT NULL DEFAULT false;

UPDATE products SET is_featured = true WHERE product_id = 1;

SELECT name, is_featured FROM products ORDER BY product_id;
        name         | is_featured
---------------------+-------------
 Mechanical Keyboard | t
 USB-C Hub           | f
 27-inch Monitor     | f
(3 rows)
Make it a habit: declare flags as BOOLEAN NOT NULL DEFAULT false. The NOT NULL guarantees two states instead of three, and the DEFAULT means every existing and future row gets a sane value without any application code changing.
⚠️
The three-state trap: a nullable boolean has three values — true, false, and NULL — and WHERE is_featured = false will silently skip the NULL rows (more on that in section 11). If "unknown" is a real state in your domain, model it explicitly with a status column instead of leaning on NULL.

Dates & Times: TIMESTAMPTZ Is the Big One

This is the section that saves you a production incident. PostgreSQL has four everyday date/time types:

TypeStoresExample use
DATEA calendar date, no timeBirthday, invoice date, holiday
TIMEA clock time, no dateStore opening hours (rarely what you want alone)
TIMESTAMPDate + time, NO timezone awarenessAlmost never the right choice — see below
TIMESTAMPTZAn exact instant, stored in UTCcreated_at, ordered_at, 'when did this happen'
💡
What TIMESTAMPTZ actually stores: despite the name, it does not store a timezone. It converts your input to UTC, stores that single universal instant (8 bytes), and converts back to your session's TimeZone setting on display. One moment in time, rendered correctly for every viewer — a reader in Manila and a server in Frankfurt see the same instant in their own local clock. Plain TIMESTAMP stores the wall-clock digits you typed with no idea which timezone they meant.

See the difference in one experiment. Insert the same wall-clock value into both types, then move timezones:

06_tz_demo.sql
CREATE TABLE tz_demo (
  naive TIMESTAMP,
  aware TIMESTAMPTZ
);

SET timezone = 'UTC';
INSERT INTO tz_demo VALUES ('2026-07-18 20:00', '2026-07-18 20:00');

-- now pretend our session is a user in the Philippines
SET timezone = 'Asia/Manila';
SELECT naive, aware FROM tz_demo;
        naive        |         aware
---------------------+------------------------
 2026-07-18 20:00:00 | 2026-07-19 04:00:00+08
(1 row)

The aware column knows 20:00 UTC is 04:00 +08 the next morning in Manila. The naive column just parrots back digits — it has no idea what they mean, so it can't adapt. Every "our timestamps shifted by 8 hours after we moved servers" bug traces back to a naive TIMESTAMP.

1Getting the current date and time
now_demo.sql
SELECT CURRENT_DATE AS today,
       now()        AS this_exact_instant;
   today    |      this_exact_instant
------------+-------------------------------
 2026-07-18 | 2026-07-18 17:42:10.532104+08
(1 row)

(now() shows +08 because our session timezone is still Asia/Manila from the demo above.)

2AT TIME ZONE — convert an instant to a local wall clock

AT TIME ZONE answers "what did the clock on the wall say in city X at this instant?" — perfect for reports:

at_time_zone.sql
SELECT order_id,
       ordered_at,
       ordered_at AT TIME ZONE 'Asia/Manila' AS manila_wall_clock
FROM orders
ORDER BY order_id;
 order_id |          ordered_at           |     manila_wall_clock
----------+-------------------------------+----------------------------
        1 | 2026-07-18 17:05:44.120933+08 | 2026-07-18 17:05:44.120933
        2 | 2026-07-18 17:05:44.120933+08 | 2026-07-18 17:05:44.120933
(2 rows)

Note the result of AT TIME ZONE has no +08 suffix — it's a plain local wall-clock reading, which is exactly what a "time" column in a report should be.

3INTERVAL — date math that just works
interval_demo.sql
SELECT now() + INTERVAL '7 days'          AS next_week,
       CURRENT_DATE + INTERVAL '30 days'  AS payment_due,
       now() - INTERVAL '1 hour 30 minutes' AS ninety_min_ago;
           next_week           |     payment_due     |        ninety_min_ago
-------------------------------+---------------------+-------------------------------
 2026-07-25 17:42:10.532104+08 | 2026-08-17 00:00:00 | 2026-07-18 16:12:10.532104+08
(1 row)

INTERVAL is also a column type in its own right — a rental duration, a subscription period. And one teaser for the road: date_trunc collapses timestamps to a boundary, the backbone of every "orders per month" report you'll ever write:

date_trunc_teaser.sql
SELECT date_trunc('month', now()) AS month_bucket;
      month_bucket
------------------------
 2026-07-01 00:00:00+08
(1 row)
⚠️
Default to TIMESTAMPTZ. Plain TIMESTAMP silently discards any timezone offset you hand it ('2026-07-18 20:00+08' becomes just 20:00), and its meaning changes whenever the server or session timezone does. The only common case for the naive types is a future local wall-clock commitment — "the store opens at 09:00" — where the clock reading itself is the data. For "when did this happen," it's TIMESTAMPTZ, always.

UUID: IDs You Can Show the World

A UUID is a 128-bit identifier that looks like a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11. Since PostgreSQL 13, generating one needs no extension:

uuid_demo.sql
SELECT gen_random_uuid() AS fresh_uuid;
              fresh_uuid
--------------------------------------
 8f14c9a2-51d3-4e6b-9c07-2ab54f1e83d9
(1 row)

The classic pattern in real systems: keep a compact IDENTITY integer as the internal primary key, and add a UUID as the public identifier you expose in URLs and APIs:

07_add_public_id.sql
ALTER TABLE customers
  ADD COLUMN public_id UUID NOT NULL DEFAULT gen_random_uuid();

SELECT customer_id, full_name, public_id FROM customers ORDER BY customer_id;
 customer_id |   full_name    |              public_id
-------------+----------------+--------------------------------------
           1 | Maria Santos   | 3c9e6f2a-8b41-4d5c-a7e9-15f2d8c04b6e
           2 | Juan dela Cruz | b7d21c48-06aa-4f3e-8d92-c5e7a1904f13
           3 | Ana Reyes      | 61f8ab05-92c4-47d1-b3a8-7e0c95d2ef4a
(3 rows)

Why not just UUIDs everywhere, or integers everywhere? Trade-offs:

AspectIDENTITY integer PKUUID PK
Size4 bytes (8 for BIGINT)16 bytes — bigger rows, bigger indexes
GuessabilitySequential — /orders/42 invites /orders/43Random — unguessable, safe in public URLs
GenerationDatabase hands them out one by oneAny client can generate offline (great for distributed apps)
Index behaviorNew rows append neatly at the index endRandom v4 values scatter across the index (slower bulk inserts)
ReadabilityEasy to say out loud: "order 42"Nobody dictates a UUID over the phone
Merging datasetsIDs collide across databasesGlobally unique — merge without conflict
💡
Storage note: use the real UUID type, never TEXT. As text, the same value costs 36+ bytes and compares character-by-character; as UUID it's a fixed 16 bytes with fast binary comparison — and the type rejects malformed values for free. Validation via type, again.

Arrays: A List Inside a Column

PostgreSQL lets any type become a list: TEXT[], INTEGER[], even TIMESTAMPTZ[]. The everyday use case is tags:

08_add_tags.sql
ALTER TABLE products
  ADD COLUMN tags TEXT[] NOT NULL DEFAULT '{}';

-- two ways to write an array value:
UPDATE products
SET tags = ARRAY['mechanical', 'rgb', 'hot-swap']   -- constructor syntax
WHERE product_id = 1;

UPDATE products
SET tags = '{"usb-c", "7-port", "travel"}'          -- literal syntax
WHERE product_id = 2;

Both syntaxes produce the same thing. Querying "which products have this tag?" uses ANY — read it as "the value 'rgb' equals any element of tags":

array_query.sql
SELECT name, tags
FROM products
WHERE 'rgb' = ANY (tags);
        name         |           tags
---------------------+---------------------------
 Mechanical Keyboard | {mechanical,rgb,hot-swap}
(1 row)

And when you need one row per element — say, to count tag popularity — unnest explodes the array (full treatment in a later post):

unnest_teaser.sql
SELECT unnest(tags) AS tag
FROM products
WHERE product_id = 1;
    tag
------------
 mechanical
 rgb
 hot-swap
(3 rows)
⚠️
Arrays vs a join table: arrays shine for small, self-contained lists that belong to one row (tags, aliases, phone numbers). But the moment the elements have their own data (a tag with a description, a color) or you need to query "all products per tag" constantly and enforce which tags exist — that's a foreign key relationship, and a proper product_tags join table will treat you far better. An array element can't have a REFERENCES constraint; typo'd tags like 'rbg' will happily sneak in.

JSONB: Flexible Documents, Real Queries

Sometimes different rows legitimately need different fields. A keyboard has a switch type; a monitor has a panel type and refresh rate. Rather than 40 mostly-NULL columns, give the variable part of the row a JSONB column:

09_add_attributes.sql
ALTER TABLE products
  ADD COLUMN attributes JSONB NOT NULL DEFAULT '{}';

UPDATE products
SET attributes = '{"switch": "red", "keys": 87, "backlight": true}'
WHERE product_id = 1;

UPDATE products
SET attributes = '{"panel": "IPS", "refresh_hz": 144, "backlight": true}'
WHERE product_id = 3;

Two operators pull values out, and the difference matters: -> returns JSONB (still JSON — you can chain deeper), while ->> returns text (ready to compare, display, or cast):

jsonb_extract.sql
SELECT name,
       attributes ->> 'switch' AS switch_as_text,
       attributes ->  'keys'   AS keys_as_jsonb
FROM products
WHERE product_id = 1;
        name         | switch_as_text | keys_as_jsonb
---------------------+----------------+---------------
 Mechanical Keyboard | red            | 87
(1 row)

The containment operator @> asks "does this JSONB contain this shape?" — the idiomatic way to filter on document fields:

jsonb_containment.sql
SELECT name, attributes
FROM products
WHERE attributes @> '{"backlight": true}';
        name         |                       attributes
---------------------+---------------------------------------------------------
 Mechanical Keyboard | {"keys": 87, "switch": "red", "backlight": true}
 27-inch Monitor     | {"panel": "IPS", "backlight": true, "refresh_hz": 144}
(2 rows)
💡
JSON vs JSONB in one line: JSON stores the raw text you typed (whitespace, key order, duplicates and all); JSONB parses it into a binary format that's indexable and fast to query. Use JSONB, basically always.
⚠️
JSONB is not an excuse to skip schema design. If you catch yourself putting price, email, or status — fields every row has and queries constantly — inside a JSONB blob, stop: those deserve real columns with real types, constraints, and foreign keys. JSONB is right for genuinely variable, per-row-shaped data: product specs, third-party API payloads, user preferences. The rule of thumb: if you would WHERE or JOIN on it weekly, it's a column.

Our products table has grown quite a wardrobe of types. Check the full picture:

psql
\d products
                                Table "public.products"
   Column    |         Type         | Collation | Nullable |           Default
-------------+----------------------+-----------+----------+------------------------------
 product_id  | integer              |           | not null | generated always as identity
 name        | text                 |           | not null |
 category    | text                 |           | not null |
 price       | numeric(10,2)        |           | not null |
 stock       | integer              |           | not null | 0
 sku         | character varying(8) |           |          |
 is_featured | boolean              |           | not null | false
 tags        | text[]               |           | not null | '{}'::text[]
 attributes  | jsonb                |           | not null | '{}'::jsonb
Indexes:
    "products_pkey" PRIMARY KEY, btree (product_id)
Check constraints:
    "products_price_check" CHECK (price >= 0::numeric)

Constrained Values: CHECK vs ENUM

Our orders.status is TEXT — which means nothing stops a buggy script from writing 'paidd'. Two tools lock a column to a fixed set of values. First, the CHECK constraint:

1Option A — TEXT + CHECK constraint
10_status_check.sql
ALTER TABLE orders
  ADD CONSTRAINT orders_status_check
  CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'));

-- now try to sneak in a bad value:
INSERT INTO orders (customer_id, status) VALUES (1, 'teleported');
ALTER TABLE
ERROR:  new row for relation "orders" violates check constraint "orders_status_check"
DETAIL:  Failing row contains (3, 1, teleported, 2026-07-18 17:58:03.44182+08).
2Option B — a dedicated ENUM type

An ENUM creates a brand-new type whose only legal values are the ones you list. It reads beautifully in \d output and stores compactly:

11_enum_type.sql
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled');

-- a new table could then declare:
--   status order_status NOT NULL DEFAULT 'pending'

-- adding a value later is easy...
ALTER TYPE order_status ADD VALUE 'refunded';
CREATE TYPE
ALTER TYPE
⚠️
The ALTER-ENUM pain: adding a value is the only easy change. You cannot drop a value, cannot rename one before PostgreSQL 10, and cannot reorder them — "removing" a value means creating a new type, rewriting every column that uses the old one, and dropping it. ENUMs also travel poorly across databases and ORMs (migration tools frequently mishandle them). They look tidy on day one and negotiate like a hostage-taker on day 400.
Our recommendation for beginners: use TEXT + CHECK for small fixed sets — changing the set is a single fast ALTER TABLE ... DROP CONSTRAINT / ADD CONSTRAINT. When the set of values grows its own data (a label, a color, a sort order), promote it to a lookup table with a foreign key. Keep ENUM for values that are truly frozen forever — days of the week, card suits.

Casting & Conversion: ::, CAST, to_char, to_date

Types are strict; casting is how you convert on purpose. PostgreSQL has two spellings — :: (the Postgres shorthand you've seen all post) and standard-SQL CAST():

casting_basics.sql
SELECT '42'::integer          AS shorthand,
       CAST('42' AS integer)  AS standard_sql;
 shorthand | standard_sql
-----------+--------------
        42 |           42
(1 row)

A cast that can't work fails immediately with a clear message — validation again:

cast_error.sql
SELECT '3,499'::integer AS price;
ERROR:  invalid input syntax for type integer: "3,499"

(Yes — the thousands separator breaks it. Strip formatting before casting.) PostgreSQL also casts implicitly when it's safe and unambiguous: insert the string '2026-07-18' into a DATE column and it converts without a :: in sight. But implicit rules hide one classic trap — integer division:

integer_division.sql
SELECT 10 / 4            AS int_division,
       10 / 4.0          AS with_decimal,
       10::numeric / 4   AS explicit_cast;
 int_division |    with_decimal    |   explicit_cast
--------------+--------------------+--------------------
            2 | 2.5000000000000000 | 2.5000000000000000
(1 row)

10 / 4 is integer ÷ integer, so PostgreSQL stays in integer land and truncates to 2. One operand cast to NUMERIC promotes the whole expression. Every "average rating shows 3 instead of 3.7" bug is this.

Finally, the formatting pair: to_char renders dates/numbers as text for humans, and to_date / to_timestamp parse text using an explicit pattern — the safe way to ingest ambiguous formats like 18/07/2026:

to_char_to_date.sql
SELECT to_char(now(), 'Mon DD, YYYY HH24:MI')   AS pretty,
       to_date('18/07/2026', 'DD/MM/YYYY')     AS parsed;
      pretty       |   parsed
-------------------+------------
 Jul 18, 2026 17:59 | 2026-07-18
(1 row)

NULL: Not Zero, Not Empty, Not False

NULL works with every type, so it deserves a section in a types guide. NULL means "unknown" — not 0, not '', not false. And here's the mind-bender: NULL is not even equal to itself.

null_equality.sql
SELECT NULL = NULL   AS equals_itself,
       NULL IS NULL  AS is_null;
 equals_itself | is_null
---------------+---------
               | t
(1 row)

NULL = NULL returns… NULL (displayed as blank). Logic: "is one unknown equal to another unknown?" — unknown! That's why comparing against NULL always uses IS NULL / IS NOT NULL. And NULL infects everything it touches:

null_propagation.sql
SELECT 100 + NULL       AS math_result,
       'hello ' || NULL AS concat_result;
 math_result | concat_result
-------------+---------------
             |
(1 row)

Both expressions collapse to NULL — this is called NULL propagation. It even changes what aggregates count. Remember Ana Reyes has no city:

null_and_count.sql
SELECT count(*)    AS all_rows,
       count(city) AS rows_with_city
FROM customers;
 all_rows | rows_with_city
----------+----------------
        3 |              2
(1 row)
💡
Mental model: treat NULL as a shrug 🤷 — "value unknown / not applicable." Any operation involving a shrug produces a shrug; only IS NULL asks the question directly. When you need a fallback for display or math, COALESCE(city, 'Unknown') returns the first non-NULL argument. And the type connection: NOT NULL on a column is part of choosing its type — decide at design time whether "unknown" is a legal state.

Common Mistakes Recap (Learn Them Here, Not in Prod)

The four type mistakes we see most, condensed. You've met the first three — the fourth deserves a demo.

⚠️
1. Money in FLOAT. SUM() ten ₱0.10 rows and get ₱0.9999999999999999. Totals drift, equality checks fail, finance loses trust in your reports. Fix: NUMERIC(10,2) from day one — retrofitting means auditing every stored value that's already slightly off.
⚠️
2. TIMESTAMP without time zone for events. The stored digits mean whatever the writing session's timezone happened to be — undocumented, unrecoverable. The bug ships the day your host, your container image, or your hosting region changes. Fix: TIMESTAMPTZ for every "when did this happen" column.
⚠️
3. VARCHAR(n) everywhere "for performance." In PostgreSQL there is no performance difference — you're just planting arbitrary limits that explode years later when a real name is 51 characters and your column is VARCHAR(50). Fix: TEXT, plus a CHECK when a limit is a genuine business rule.
⚠️
4. Dates stored as TEXT. Text sorts alphabetically, not chronologically — so your "order by date" is quietly wrong, comparisons like < '06/01/2026' are meaningless, and date math is impossible. Watch it fail:
dates_as_text_bug.sql
CREATE TABLE bad_dates (
  event       TEXT,
  happened_on TEXT          -- 😬 should be DATE
);

INSERT INTO bad_dates VALUES
  ('Product launch', '12/01/2025'),
  ('Big sale',       '03/15/2025'),
  ('Annual audit',   '01/02/2026');

SELECT event, happened_on
FROM bad_dates
ORDER BY happened_on;       -- "chronological", supposedly
     event      | happened_on
----------------+-------------
 Annual audit   | 01/02/2026
 Big sale       | 03/15/2025
 Product launch | 12/01/2025
(3 rows)

The 2026 audit sorts first because the string '01/…' is alphabetically smallest. No error, no warning — just silently wrong ordering in every report. With a real DATE column, ORDER BY is chronological, comparisons work, and malformed input like '13/45/2025' is rejected at the door.

The Decision Guide: You're Storing X → Use Y

Bookmark this table. It compresses everything above into one lookup:

You're storing…UseWhy
Money: prices, totals, salariesNUMERIC(10,2)Exact decimal math — no float drift
Whole counts: stock, quantityINTEGER4 bytes, fits ±2.1 billion, fails loudly on overflow
Huge counters: views, event logsBIGINTOutgrowing INTEGER later means a painful rewrite
Scientific measurements, sensor dataDOUBLE PRECISIONData is approximate anyway; floats are fast
Names, emails, descriptionsTEXTNo length penalty in PostgreSQL; limits only when real
Fixed-length codes (PH, USD)TEXT + CHECK(char_length(...) = n)Enforces length without CHAR's padding gotchas
On/off flags: active, featuredBOOLEAN NOT NULL DEFAULT falseTwo states, never three
“When did this happen”TIMESTAMPTZOne UTC instant, correct in every timezone
Calendar dates: birthday, due dateDATENo fake midnight time attached
Durations: rental period, timeoutINTERVALNative date math: now() + INTERVAL '7 days'
Public IDs in URLs / APIsUUIDUnguessable, 16 bytes, generate anywhere
Small value lists: tags, aliasesTEXT[]One row owns the list; query with ANY()
Variable per-row attributes / API payloadsJSONBFlexible shape, indexable, real query operators
A fixed set of states: order statusTEXT + CHECK (or lookup table)Easy to change the set later, unlike ENUM
Type-picking best practices:
  • Let the type do the first round of validation — pick the narrowest type that fits the domain.
  • Exact data (money, counts) → exact types. Measurements → floats. Never mix them up.
  • Default to TEXT, TIMESTAMPTZ, NUMERIC for money — the boring trio that never bites.
  • Add NOT NULL unless "unknown" is genuinely meaningful for that column.
  • Reach for arrays/JSONB only after asking "should this be a column or another table?"
  • When in doubt, choose the option that's easiest to change later — CHECK over ENUM, BIGINT over a future migration.

Practice Exercises + What's Next

Time to make it stick. Imagine we're adding a co-working space booking system to our web shop's database. Work through these against your playground:

1Design the bookings table

Choose a type for each column of a bookings table: an internal ID, a public ID for URLs, the guest's name, a 6-character room code, when the booking starts, how long it lasts, the price per hour, a paid/unpaid flag, a list of requested amenities, a free-form metadata payload from the booking widget, a status that's one of 'pending' / 'confirmed' / 'cancelled', and when the row was created. Write the full CREATE TABLE before peeking below.

2Prove the money rule

Write one SELECT that shows the float error for summing twenty bookings of ₱0.10, next to the exact NUMERIC total.

3Timezone check

Insert a booking starting '2026-08-01 09:00+08' and display it as a Manila wall-clock time using AT TIME ZONE.

4Guard the status column

Add a CHECK constraint for the three statuses, then try inserting 'maybe' and confirm you get the constraint error.

5Query the amenities array

Find all bookings that requested a projector.

Expected answer for exercise 1 (yours may differ in names, not in types):

answer_bookings.sql
CREATE TABLE bookings (
  booking_id     INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  public_id      UUID NOT NULL DEFAULT gen_random_uuid(),
  guest_name     TEXT NOT NULL,
  room_code      TEXT NOT NULL CHECK (char_length(room_code) = 6),
  starts_at      TIMESTAMPTZ NOT NULL,
  duration       INTERVAL NOT NULL,
  price_per_hour NUMERIC(10,2) NOT NULL CHECK (price_per_hour >= 0),
  is_paid        BOOLEAN NOT NULL DEFAULT false,
  amenities      TEXT[] NOT NULL DEFAULT '{}',
  metadata       JSONB NOT NULL DEFAULT '{}',
  status         TEXT NOT NULL DEFAULT 'pending'
                 CHECK (status IN ('pending', 'confirmed', 'cancelled')),
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

Expected answers for exercises 2, 3, and 5:

answer_queries.sql
-- Exercise 2: the money rule
SELECT SUM(0.10::float8)  AS float_total,
       SUM(0.10::numeric) AS exact_total
FROM generate_series(1, 20);

-- Exercise 3: timezone check
INSERT INTO bookings (guest_name, room_code, starts_at, duration, price_per_hour)
VALUES ('Carlo Garcia', 'ROOM-A', '2026-08-01 09:00+08', INTERVAL '2 hours', 350.00);

SELECT guest_name,
       starts_at AT TIME ZONE 'Asia/Manila' AS manila_start
FROM bookings;

-- Exercise 5: amenities array
SELECT guest_name, room_code
FROM bookings
WHERE 'projector' = ANY (amenities);
    float_total     | exact_total
--------------------+-------------
 2.0000000000000004 |        2.00
(1 row)

  guest_name  |    manila_start
--------------+---------------------
 Carlo Garcia | 2026-08-01 09:00:00
(1 row)

(Exercise 4 should reward you with ERROR: new row for relation "bookings" violates check constraint — if it doesn't, the constraint isn't attached. Exercise 5 returns (0 rows) until you insert a booking with amenities = ARRAY['projector', 'whiteboard'] — try it.)

🚀 What's next in the PostgreSQL series:

Recap: the column type is the validation layer nobody can bypass. NUMERIC for money, TEXT for strings, TIMESTAMPTZ for moments in time, BOOLEAN NOT NULL for flags, UUID for public IDs, arrays and JSONB for the genuinely flexible parts — and a CHECK constraint wherever a value set is fixed. Choose deliberately once, and the database quietly enforces your decision for years.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.