PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 3: Data types

Choosing data types

Level 1: Foundations20 min

PostgreSQL has a rich type system. The right type documents intent, enforces validity and keeps storage and comparisons fast; the wrong one causes rounding errors, time zone bugs and silent truncation.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Choose between numeric and floating point, and between timestamp and timestamptz
  • Use identity columns and UUIDs for keys
  • Cast values with :: and CAST and understand when casts fail

conceptThe concept

Numbers: integer (4 bytes) and bigint (8 bytes) for counts and keys; numeric(p,s) for exact decimals such as money; double precision for measurements where tiny binary rounding is acceptable.

Text: text and varchar(n) are stored the same way; varchar(n) just adds a length check. Prefer text plus a CHECK constraint if you need a limit you may change later.

Time: timestamptz stores an absolute instant (internally UTC) and displays it in the session's TimeZone. timestamp without time zone is a wall-clock reading with no zone attached. For events that happened at a moment, use timestamptz.

Keys: GENERATED ALWAYS AS IDENTITY is the standard replacement for serial. UUIDs avoid central coordination; uuidv7() (PostgreSQL 18) is time-ordered, so new keys land at the end of the index instead of on random pages.

Casts: value::type is PostgreSQL shorthand for CAST(value AS type). A cast that cannot be represented fails with an error rather than guessing.

whyWhy it matters

Float money produces cents that do not add up. A timestamp column filled by servers in different zones produces orders that seem to arrive before they were placed. Switching a hot table's key from integer to bigint after it runs out at 2,147,483,647 is a painful table rewrite. All three are type decisions made on day one.

Use caseReal-world scenario

The shop uses numeric(10,2) for prices, timestamptz for every event time, jsonb for loosely structured product attributes and a text[] array for tags.

Try itExamples

Run and edit these in the interactive lesson.

Exact versus binary decimals
SELECT 0.1::double precision + 0.2::double precision AS float_sum,
       0.1::numeric + 0.2::numeric AS numeric_sum,
       pg_typeof(0.1) AS literal_type;
timestamptz is an instant, shown in your session time zone
SELECT now() AS server_now,
       now() AT TIME ZONE 'Asia/Kolkata' AS kolkata_wall_clock,
       timestamptz '2025-07-01 09:00 Europe/Berlin' AS berlin_meeting,
       pg_typeof(now() AT TIME ZONE 'Asia/Kolkata') AS result_type;
SHOW TimeZone;
Casting, and how it fails
SELECT '42'::int + 1 AS int_math,
       CAST('2025-01-31' AS date) + 1 AS next_day,
       interval '1 day' * 3 AS three_days,
       'abc'::varchar(2) AS explicit_cast_truncates;
SELECT 'forty-two'::int;
The last statement fails with invalid input syntax. Note the surprise in the first statement: an explicit cast to varchar(2) truncates silently, while inserting a too-long value into a varchar(2) column raises an error.
Keys and sizes
SELECT gen_random_uuid() AS random_uuid,
       uuidv7() AS time_ordered_uuid,
       pg_column_size(1::smallint) AS int2_bytes,
       pg_column_size(1::integer) AS int4_bytes,
       pg_column_size(1::bigint) AS int8_bytes,
       pg_column_size(1.50::numeric) AS numeric_bytes;

internalsUnder the hood

Each value has a storage size and alignment. Column order affects padding: an int4 followed by an int8 wastes 4 bytes per row. pg_column_size() shows the bytes a value takes. Values larger than about 2 kB are compressed and/or moved to a TOAST table automatically.

Avoid / preferDo this, not that

Avoid
CREATE TABLE invoices (amount real);
Prefer
CREATE TABLE invoices (amount numeric(12,2));

Binary floating point cannot represent most decimal fractions exactly; sums of money drift by fractions of a cent. numeric is exact.

Avoid
CREATE TABLE logins (at timestamp);
Prefer
CREATE TABLE logins (at timestamptz);

timestamp without time zone stores wall-clock time with no zone, so the same value means different instants on servers in different zones. timestamptz stores an instant.

PitfallsCommon mistakes

  • Storing money in real or double precision. Use numeric or integer cents.
  • Using timestamp (without time zone) for event times. Use timestamptz and convert for display.
  • Using integer keys on tables that could pass two billion rows, or serial where GENERATED ... AS IDENTITY is clearer and harder to misuse.

LimitsWhen not to use it

Do not use char(n), and do not pick varchar(255) out of habit. In PostgreSQL text performs the same; add a CHECK on length only when the limit is a real business rule.

SecuritySecurity notes

Never store passwords in a text column, even encrypted. Store a slow salted hash (bcrypt, scrypt or Argon2) produced by the application or pgcrypto's crypt().

ChallengeTry it yourself

In one row, show whether 0.1 + 0.2 equals 0.3 as float8 and as numeric, and whether '2025-13-01' is a valid date, without raising an error.

Show one solution
One solution
SELECT 0.1::float8 + 0.2::float8 = 0.3 AS float_equal,
       0.1::numeric + 0.2 = 0.3 AS numeric_equal,
       pg_input_is_valid('2025-13-01', 'date') AS valid_date;

InterviewQuestions you should be able to answer

timestamp versus timestamptz: which should you use and why?
timestamptz stores an absolute point in time (normalized to UTC) and converts to the session TimeZone on output, so it is correct for events. timestamp stores a wall-clock value with no zone, correct only for things like "store opens at 09:00 local time". Neither actually stores the original zone; if you need it, keep it in a separate column.
Why might you choose uuidv7 over gen_random_uuid for a primary key?
Random (v4) UUIDs scatter inserts across the whole B-tree, causing page splits, poor cache locality and more WAL from full-page writes. UUIDv7 puts a millisecond timestamp in the high bits, so new keys are roughly increasing and inserts hit the rightmost leaf like a sequence, while remaining globally unique. PostgreSQL 18 adds uuidv7() natively; earlier versions need an extension or application code.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation