"Databases, Schemas, Tables, and Basic SQL Syntax" read BIGSERIAL, VARCHAR(255), and TIMESTAMP in this project's own CREATE TABLE statements without stopping to ask what each type actually guarantees, or how it ends up as a Long, a String, or a LocalDateTime back in Java. This lesson answers that — using this project's own real columns, not invented ones.
Numeric Types: integer, bigint, and This Project's Own Choice of BIGSERIAL
PostgreSQL's two everyday whole-number types are INTEGER (4 bytes, roughly ±2.1 billion) and BIGINT (8 bytes, roughly ±9.2 quintillion). BIGSERIAL, used for every id column in V1__init_schema.sql, isn't a distinct storage type — it's BIGINT plus an automatically created sequence that generates the next value, which is exactly what backs GenerationType.IDENTITY on this project's own Topic.id:
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
BIGINT/BIGSERIAL maps to Java's Long, INTEGER/SERIAL to Integer — this project's topic.estimated_minutes column (plain INTEGER, no auto-generation) is exactly why Topic.estimatedMinutes is declared Integer, not Long: it's a small, human-entered number, never an identity column, so BIGINT's extra range buys nothing. Choosing BIGINT/BIGSERIAL for every primary key, even in a table that will never approach a billion rows, is a defensive default worth keeping — changing a primary key's type later, after foreign keys reference it, is far more disruptive than the few extra bytes BIGINT costs up front.
Text Types: varchar vs. text
PostgreSQL has three string types, and the difference is smaller than it looks: VARCHAR(n) enforces a maximum length of n characters; VARCHAR with no length and TEXT are functionally identical, unlimited-length strings. Internally, PostgreSQL stores all three the same way and applies no performance penalty to TEXT over a length-limited VARCHAR — unlike some other databases, where TEXT is a slower, separately stored type.
This project's own columns show both choices used deliberately: topic.slug VARCHAR(255) (from V1__init_schema.sql) caps a value that's used in URLs and file paths, where an unbounded length would be a bug, not a feature; topic_translation.summary, by contrast, is mapped in Java with an explicit columnDefinition:
@Column(columnDefinition = "TEXT")
private String summary;
A lesson summary has no natural length ceiling, so TEXT is the honest choice — no arbitrary cap to bump into later. Both map to Java's String; the type alone never tells you which one you're looking at without checking columnDefinition or the migration itself.
boolean
BOOLEAN stores exactly true, false, or NULL — no 0/1 integer substitute, unlike some databases. This project's topic_translation.published column is a real, plain BOOLEAN NOT NULL, mapped straightforwardly to Java's primitive boolean:
@Column(nullable = false)
private boolean published;
Note the NOT NULL here isn't optional in practice — a primitive boolean field in Java can never hold null, so if the column allowed it, a NULL value read from the database would have nowhere valid to go. This is a first, concrete look at a pattern worth remembering generally: a NOT NULL column and a non-nullable Java type (a primitive, as opposed to a boxed Boolean or Integer) need to agree, or reading a row can fail in ways that have nothing to do with application logic.
Date and Time Types: date, timestamp, and timestamptz
PostgreSQL has three commonly used temporal types. DATE stores a calendar date only — no time component. TIMESTAMP (short for TIMESTAMP WITHOUT TIME ZONE) stores a date and time with no time zone attached at all — just a naive point in time, as written. TIMESTAMPTZ (TIMESTAMP WITH TIME ZONE) stores a point in time that PostgreSQL always normalizes to UTC internally, converting to and from whatever time zone the connecting client is in.
This project's own question table (added in a later migration, after V1__init_schema.sql) uses plain TIMESTAMP:
ALTER TABLE question
ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now(),
ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT now();
mapped to java.time.LocalDateTime — a type that, not coincidentally, also carries no time zone of its own:
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
LocalDateTime for TIMESTAMP, and (were this project to use it) java.time.Instant or OffsetDateTime for TIMESTAMPTZ, is the natural pairing in each direction — mixing them (mapping a TIMESTAMPTZ column to LocalDateTime, say) silently discards the time zone information PostgreSQL was tracking. now(), seen above, is a PostgreSQL built-in function returning the current transaction's timestamp — not a value supplied by the application, which is why created_at/updated_at get a real default even for rows inserted directly by SQL, independent of any Java code path.
Reading a Real Column's Type with psql
\d <table> (already used in "Connecting to PostgreSQL" and "Databases, Schemas, Tables, and Basic SQL Syntax") is the fastest way to check a column's actual type without opening a migration file at all:
learning=# \d topic_translation
Table "public.topic_translation"
Column | Type | Collation | Nullable | Default
-------------------+------------------------+-----------+----------+---------
id | bigint | | not null |
topic_id | bigint | | not null |
language | character varying(5) | | not null |
title | character varying(255) | | not null |
summary | text | | |
seo_title | character varying(255) | | |
seo_description | character varying(500) | | |
published | boolean | | not null |
Note that psql reports character varying(255) rather than VARCHAR(255) and bigint rather than BIGINT — PostgreSQL's internal type names are lowercase and occasionally longer than the SQL keyword used to declare them; both refer to the identical type.
From SQL Type to Java Field: How Hibernate Bridges the Gap
Every mapping shown above — BIGINT↔Long, VARCHAR/TEXT↔String, BOOLEAN↔boolean, TIMESTAMP↔LocalDateTime — is applied by Hibernate automatically, without a single explicit type-conversion annotation anywhere in Topic, TopicTranslation, or Question. This is a direct consequence of the JDBC driver and Hibernate dialect layer that "JPA, Hibernate, and Spring Data JPA" already covered — this lesson isn't introducing new machinery, only naming, concretely, which PostgreSQL type each of this project's real Java field types corresponds to on the other side of that layer. The one place this project does make a mapping explicit rather than relying on inference is exactly the TEXT case above (columnDefinition = "TEXT") — because Hibernate's own default for a bare String field is a length-capped VARCHAR, not TEXT, so an unbounded column has to be requested deliberately.
Common Misconceptions
"VARCHAR(255) is faster than TEXT." Not in PostgreSQL — internally identical, with identical performance; the length limit is purely a data-integrity choice, not a performance one. "A BOOLEAN column can be 0 or 1." No — PostgreSQL's BOOLEAN is a real three-valued type (true/false/NULL); 0/1 are integers, a different type entirely, even though some client libraries accept them as loose input. "TIMESTAMP includes time zone handling." It's the opposite — plain TIMESTAMP explicitly has no time zone; TIMESTAMPTZ is the one that does, despite TIMESTAMP being the more commonly reached-for name.
Best Practices
- Default to
BIGINT/BIGSERIALfor primary keys even when a table will stay small — this project does so uniformly inV1__init_schema.sql, and it costs nothing meaningful up front while avoiding a disruptive later migration. - Prefer
TEXTfor genuinely unbounded content (liketopic_translation.summary) and reserveVARCHAR(n)for values with a real, meaningful upper bound (like aslugused in a URL) — let the length limit express a business rule, not a guess. - Match a
NOT NULLcolumn to a Java primitive (boolean,int,long) and a nullable column to the corresponding boxed type (Boolean,Integer,Long) — this project'spublishedfield is a clean example of the primitive side of that rule. - Choose
TIMESTAMPTZover plainTIMESTAMPfor any new time-tracking column in a project that might ever run across time zones — treat this project's own use of plainTIMESTAMPforquestion.created_at/updated_atas a real, existing trade-off worth being aware of, not a pattern to copy without thinking about it.
Common Mistakes
- Assuming
VARCHARwithout a length behaves differently fromTEXT— they don't, in PostgreSQL specifically; carrying that assumption over from another database leads to unnecessary micro-optimization. - Mapping a
TIMESTAMPTZcolumn toLocalDateTime(or aTIMESTAMPcolumn toInstant/OffsetDateTime) — both compile and often even run without error, but silently lose or fabricate time zone information at read or write time. - Forgetting that a Java primitive field cannot hold a
NULLread from a nullable column — the failure surfaces as an unchecked exception at the Hibernate/JDBC layer, far from the column definition that actually caused it. - Picking
INTEGERfor a primary key to "save space," then hitting real range limits (or a disruptive type-widening migration) once the table grows — the bytes saved rarely justify the risk for anidcolumn specifically.
Summary, Cheat Sheet, and Glossary
Summary
INTEGER/BIGINTmap to Java'sInteger/Long;BIGSERIALisBIGINTplus an auto-incrementing sequence, backingGenerationType.IDENTITYon this project's own primary keys.VARCHAR(n), unboundedVARCHAR, andTEXTare stored identically in PostgreSQL and all map to Java'sString— only the length cap differs, and it should express a real constraint, not a performance choice.BOOLEANis a genuine three-valued type (true/false/NULL), mapping cleanly to a Java primitivebooleanonly when the column isNOT NULL.TIMESTAMPcarries no time zone and maps toLocalDateTime;TIMESTAMPTZdoes carry one (normalized to UTC internally) and maps toInstant/OffsetDateTime— this project's ownquestiontable uses plainTIMESTAMP.- Every SQL-to-Java type mapping shown here is handled automatically by Hibernate's JDBC dialect layer (already covered in "JPA, Hibernate, and Spring Data JPA") — no explicit conversion code exists anywhere in this project's own entities.
Cheat Sheet
INTEGER / SERIAL ↔ Integer
BIGINT / BIGSERIAL ↔ Long
VARCHAR(n) / TEXT ↔ String
BOOLEAN ↔ boolean (if NOT NULL) / Boolean (if nullable)
DATE ↔ LocalDate
TIMESTAMP ↔ LocalDateTime
TIMESTAMPTZ ↔ Instant / OffsetDateTime
\d <table> -- see a table's real column types (psql)
Glossary
- BIGSERIAL:
BIGINTplus an auto-incrementing sequence PostgreSQL creates and manages implicitly — the SQL-level mechanism behindGenerationType.IDENTITY. - TEXT: an unbounded-length string type, stored identically to
VARCHARinternally in PostgreSQL. - TIMESTAMPTZ: a timestamp type that tracks time zone information, normalized to UTC internally and converted for the connecting client.
- now(): a PostgreSQL built-in function returning the current transaction's timestamp, usable directly as a column
DEFAULT.