Skip to content
Joey Wang
Menu

Search

DevOps and Reliability

PostgreSQL JSONB vs Linking Tables for Relationships

Comparing PostgreSQL jsonb columns against a traditional linking table for modeling course prerequisites, and where a hybrid of both makes more sense.

· 2 min read

database #postgresql#database#performance

Audio summary

Relational purity or jsonb flexibility: it’s a real design decision, and a concrete example makes the trade-offs clearer than the abstract debate does.

The problem: linking courses without repetition

A learning platform has courses made of lessons. “Advanced SQL” requires “Beginner SQL”: before a student enrolls in the advanced course, the platform needs to know every lesson from its prerequisites, so it doesn’t re-enroll them in material they’ve already covered. How do you model that requires relationship?

Path 1: jsonb

Add a jsonb column to courses and embed the relationship directly:

CREATE TABLE courses (
    course_id serial PRIMARY KEY,
    course_title text NOT NULL,
    journey_details jsonb
);

INSERT INTO courses (course_id, course_title, journey_details) VALUES
(101, 'Beginner SQL', NULL),
(102, 'Advanced SQL', '{"prerequisites": [101]}'),
(103, 'SQL for Data Science', '{"prerequisites": [101, 102], "recommended": [201]}');

The third row adds a “recommended” relationship, a type that didn’t exist a moment ago, with no ALTER TABLE. Querying it means un-nesting the array:

SELECT DISTINCT lesson.lesson_title
FROM lessons
JOIN course_lessons ON lesson.lesson_id = course_lessons.lesson_id
WHERE course_lessons.course_id IN (
    SELECT jsonb_array_elements_text(journey_details -> 'prerequisites')::int
    FROM courses
    WHERE course_id = 103
);

It works. Whether it stays correct as the data grows is a different question.

Path 2: a linking table

The textbook, third-normal-form approach:

CREATE TABLE course_prerequisites (
    course_id int REFERENCES courses(course_id) ON DELETE CASCADE,
    prerequisite_id int REFERENCES courses(course_id) ON DELETE CASCADE,
    PRIMARY KEY (course_id, prerequisite_id)
);

INSERT INTO course_prerequisites (course_id, prerequisite_id) VALUES
(102, 101),
(103, 101),
(103, 102);

The FOREIGN KEY constraints make it impossible to link to a course that doesn’t exist. The query is a plain JOIN:

SELECT DISTINCT lesson.lesson_title
FROM lessons
JOIN course_lessons ON lesson.lesson_id = course_lessons.lesson_id
JOIN course_prerequisites cp ON course_lessons.course_id = cp.prerequisite_id
WHERE cp.course_id = 103;

The trade-offs

ConsiderationjsonbLinking table
Data integrityNothing stops a prerequisite ID that points to nothing; validation is entirely the application’s job.The database rejects a bad reference at insert time; ON DELETE CASCADE handles cleanup automatically.
FlexibilityAdd a new relationship type by adding a JSON key. No migration.A new relationship type means an ALTER TABLE and a deliberate migration.
MaintainabilityDeleting a course means hunting down every jsonb column that might reference it.Deleting a course cascades through the constraint automatically.
PerformanceFast with a GIN index; jsonb is not inherently slow.Fast with an indexed foreign key; this is what relational databases are built for.

Performance is close to a wash either way. The real question is who’s responsible for the data’s correctness: the application, or the database.

The pragmatic answer

Use a linking table for the relationship that’s actually business-critical, the one where a dangling reference is a real bug. Use a jsonb column for the soft, descriptive data around it: recommendations, notes, anything that’s genuinely schema-less and doesn’t need referential integrity. course_prerequisites stays a real table; a course_metadata jsonb column can hold everything else.

The deciding question isn’t “which is faster,” it’s “is this a rule or a suggestion.” Rules belong in constraints. Suggestions can live in JSON.