A library management database schema has to keep two things apart that everyday language runs together: a book as a title, and a book as a physical object on a shelf. This one models a multi-branch lending library in 12 tables with 12 foreign key relationships, 2 junction tables and 3 context views, on PostgreSQL with snake_case naming. It covers the catalogue, the copies held at each branch, and the circulation of loans, reservations and fines. Every column, type, key and constraint below is read from the diagram, which you can open and edit at the end.
What tables does a library management database need?
publishers
The publishing houses that books are attributed to.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
name | TEXT | no | unique (publishers_name_key) | |
address | TEXT | no | default '' | |
phone | TEXT | yes | default NULL | |
updated_at | TIMESTAMPTZ | no | default CURRENT_TIMESTAMP | |
created_at | TIMESTAMPTZ | no | default NOW() |
books
A title in the catalogue, identified by ISBN. Not a physical object - that is copies.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
publisher_id | BIGINT | FK | no | to publishers.id |
isbn | TEXT | no | unique (books_isbn_key) | |
title | TEXT | no | ||
description | TEXT | no | default '' | |
publication_year | INTEGER | yes | ||
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default CURRENT_TIMESTAMP |
authors
A writer, with a free-text biography.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | also carries a redundant unique constraint, authors_id_key |
name | TEXT | no | not unique | |
bio | TEXT | no | default '' | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default CURRENT_TIMESTAMP |
books_authors
Connects a book to each of its authors.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
book_id | BIGINT | PK, FK | no | to books.id |
author_id | BIGINT | PK, FK | no | to authors.id |
categories
A subject heading a book can be filed under.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
name | TEXT | no | unique (categories_name_key) | |
description | TEXT | no | default '' | |
updated_at | TIMESTAMPTZ | no | default CURRENT_TIMESTAMP | |
created_at | TIMESTAMPTZ | no | default NOW() |
books_categories
Connects a book to each category it belongs to.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
book_id | BIGINT | PK, FK | no | to books.id |
category_id | BIGINT | PK, FK | no | to categories.id |
branches
A physical library location that holds copies.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
address | TEXT | no | ||
name | TEXT | no | ||
phone | TEXT | yes | ||
updated_at | TIMESTAMPTZ | no | default CURRENT_TIMESTAMP | |
created_at | TIMESTAMPTZ | no | default NOW() |
copies
One physical item of a book, held at one branch and identified by barcode.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
book_id | BIGINT | FK | no | to books.id |
branch_id | BIGINT | FK | no | to branches.id |
barcode | TEXT | no | unique (copies_barcode_key) | |
status | TEXT | no | default available; check: available, borrowed, reserved, maintenance, lost | |
condition | TEXT | no | default '' | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default CURRENT_TIMESTAMP |
patrons
A library member who can reserve and borrow.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
email | TEXT | no | unique (patrons_email_key) | |
first_name | TEXT | no | ||
last_name | TEXT | no | ||
membership_status | TEXT | no | default active; check: active, suspended, expired | |
phone | TEXT | yes | ||
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default CURRENT_TIMESTAMP |
reservations
A patron’s claim on a title, before any particular copy is chosen.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
book_id | BIGINT | FK | no | to books.id |
patron_id | BIGINT | FK | no | to patrons.id |
status | TEXT | no | default pending; check: pending, fulfilled, cancelled, expired | |
reserved_at | TIMESTAMPTZ | no | default NOW() |
loans
One copy in the hands of one patron, with a due date.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
copy_id | BIGINT | FK | no | to copies.id |
patron_id | BIGINT | FK | no | to patrons.id |
due_date | TIMESTAMPTZ | no | ||
status | TEXT | no | default active; check: active, returned, overdue | |
return_date | TIMESTAMPTZ | yes | NULL means the copy is still out | |
issue_date | TIMESTAMPTZ | no | default NOW() |
fines
A charge assessed against one loan.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
loan_id | BIGINT | FK | no | to loans.id |
amount | NUMERIC | no | ||
status | TEXT | no | default unpaid; check: unpaid, paid, waived | |
paid_at | TIMESTAMPTZ | yes | NULL means the fine is still owed | |
assessed_at | TIMESTAMPTZ | no | default NOW() |
How the tables relate
Every relationship here is one-to-many with a cardinality of zero or many on the child side: a publisher may have no books in the catalogue yet, a branch may hold no copies, and a loan may never attract a fine. None of the 12 foreign key columns is nullable, so no child row can exist without its parent.
| Parent | Child | Foreign key | Cardinality |
|---|---|---|---|
publishers | books | books.publisher_id | 1:N, zero or many |
books | books_authors | books_authors.book_id | 1:N, zero or many |
authors | books_authors | books_authors.author_id | 1:N, zero or many |
books | books_categories | books_categories.book_id | 1:N, zero or many |
categories | books_categories | books_categories.category_id | 1:N, zero or many |
books | copies | copies.book_id | 1:N, zero or many |
branches | copies | copies.branch_id | 1:N, zero or many |
patrons | reservations | reservations.patron_id | 1:N, zero or many |
books | reservations | reservations.book_id | 1:N, zero or many |
patrons | loans | loans.patron_id | 1:N, zero or many |
copies | loans | loans.copy_id | 1:N, zero or many |
loans | fines | fines.loan_id | 1:N, zero or many |
The two many-to-many pairs are books to authors and books to categories, and neither is a direct relationship: each is resolved through a junction table appearing above as two separate one-to-many rows. books is the most referenced table in the schema, with four children.
The three context views
A context view is a focused subset of the main diagram, showing one part of the model while the main view remains the single source of truth. This schema is divided into three of them, drawn as coloured boxes on the diagram itself. Nine of the 12 relationships stay inside a single view. The other three cross between views, and they form a triangle in which each pair of contexts touches exactly once.
catalog
Contains publishers, books, authors, books_authors, categories and books_categories - half the schema. Five of its relationships stay inside: a publisher’s books, and the two junction tables connecting books to authors and to categories. Two reach out of it, both from books, one into inventory and one into circulation. Nothing points into the catalogue from outside.
inventory
Contains branches and copies, joined by one relationship. It is the middle of the triangle: copies.book_id reaches into the catalogue, and loans.copy_id reaches in from circulation. Every physical item in the library is one row here.
circulation
Contains patrons, reservations, loans and fines. Four relationships stay inside: patrons to their reservations and loans, and loans to their fines. Two reach out, and they land in different places - reservations.book_id into the catalogue and loans.copy_id into inventory. That split is the schema’s central design decision, and it is the subject of the next section.
The three crossings are books to copies, books to reservations, and copies to loans. Counting foreign keys as they pass between groups this way is the same reading the Context Map applies to a whole schema, one level above individual tables.
Design decisions
Why does a loan point at a copy but a reservation point at a book?
This is the decision the rest of the schema hangs from. A patron reserving a title does not care which physical item satisfies it, so reservations.book_id names the work. A patron borrowing takes one specific object out of the building, so loans.copy_id names that object. Collapsing both onto books - the shortcut most tutorial schemas take - makes it impossible to answer where a particular item is, or which branch is short of stock, because the loan no longer identifies anything you can put your hands on.
The two foreign keys also leave circulation for different contexts, which is why the split shows up as structure rather than as a naming preference: one crossing lands in the catalogue and the other in inventory.
Why is there a copies table at all?
Because books and copies answer different questions and have different uniqueness. A book is unique by isbn; a copy is unique by barcode. A book has a publication year and a description; a copy has a condition, a status and a branch. Without the second table the library could hold six identical items and have no way to say which one is lost.
Why do fines hang off loans rather than patrons?
A fine exists because of a specific borrowing event, so fines.loan_id records what it was charged for. The patron is still one hop away through loans.patron_id. Attaching fines directly to a patron would store the amount owed while discarding the reason, and the reason is what a member disputes.
Why do the junction tables have no id column?
books_authors and books_categories each have exactly two columns and both are part of the primary key. A book is linked to an author once or not at all, and the composite key enforces that without a second constraint. Both names are plural on both halves, which is the signal for this shape: the pair is the key, and the pairing is not a row in its own right.
Why are return_date and paid_at nullable when the other timestamps are not?
Six of the schema’s columns permit NULL, and two of them carry meaning rather than absence. loans.return_date is NULL exactly while the copy is still out, and fines.paid_at is NULL exactly while the fine is still owed - in both cases issue_date and assessed_at are NOT NULL and default to NOW(), so the row always records when it started and leaves the end open. The other four nullable columns are contact and metadata fields: publishers.phone, branches.phone, patrons.phone and books.publication_year. Deciding which columns admit NULL, and what NULL means in each, is what stops a nullable column from quietly weakening the rules around it.
Why do the circulation tables not carry created_at and updated_at?
Every catalogue and inventory table has the created_at/updated_at pair. None of reservations, loans or fines does. Instead they carry timestamps named after the event: reserved_at, issue_date and assessed_at, each defaulting to NOW(). Catalogue rows are records that get edited, so when they last changed is worth storing. Circulation rows are events, and an event’s important time is when it happened.
Why are the status columns check constraints rather than enums or lookup tables?
Five tables carry a status: five copy statuses, three membership statuses, four reservation statuses, three loan statuses and three fine statuses, each with a default. Writing them as check constraints rather than a Postgres enum type or a lookup table keeps the permitted values in the table definition and on the entity, where Schemity renders them alongside the column. The cost is that adding a fourth loan status requires a migration.
What changes in production
Five things this schema does not yet carry that a deployed library system will need.
Nothing prevents two active loans on the same copy. loans.copy_id has no unique constraint, and it should not have a plain one - a copy is borrowed many times over its life. What is missing is a partial unique index on copy_id restricted to status = 'active', which is the constraint that actually expresses “one item, one borrower at a time”. Without it the rule lives in application code and a race can break it.
copies.status duplicates state that loans already determines. Whether an item is borrowed is derivable from an active loan against it, and storing it separately means two places can disagree. It is cheap to read and it will drift; either maintain it in a trigger or derive it.
No indexes are defined. PostgreSQL indexes primary keys and unique constraints, but not foreign key columns. All 12 foreign keys here - including loans.copy_id, loans.patron_id and fines.loan_id, the three most-queried joins in a circulation desk - will be scanned until you add one.
No deletion behaviour is recorded, and there is no soft delete. The diagram carries no ON DELETE rule, so every foreign key falls back to NO ACTION, and no table has a deleted_at. A patron with loan history cannot be deleted at all, which may well be right, but it should be a decision rather than a default.
fines.amount is unconstrained NUMERIC, and authors.name has no unique constraint where publishers.name and categories.name both do. Pin the money column to NUMERIC(10,2), and decide deliberately whether two authors may share a name - as written they may, and the authors_id_key unique constraint sitting on the primary key does nothing to help.
Open this schema in Schemity Lite
The diagram is public and openable: open the library management schema in Schemity Lite. Drag the entities into an arrangement that suits you, add the columns your version needs, and export the result as SQL, Mermaid, DBML or an image. To work against a real database instead of a browser copy, the desktop app reverse engineers a live PostgreSQL schema into the same kind of diagram.
Frequently asked questions
What tables does a library management system need?
This schema uses 12: publishers, books, authors, categories and the books_authors and books_categories junction tables for the catalogue; branches and copies for the physical stock; and patrons, reservations, loans and fines for circulation.
Should a loan reference the book or the copy?
The copy. A book is a title and a copy is a physical object with a barcode, so lending has to name the object that left the building. In this schema loans.copy_id points at copies while reservations.book_id points at books, because a patron reserves a title and does not care which copy satisfies it.
How do you model a book with several authors?
With a junction table. books_authors has a composite primary key of author_id and book_id, so a book can have several authors and an author several books. The same shape connects books to categories through books_categories.
How does the schema record that a book has not been returned yet?
By leaving loans.return_date NULL. The column is the only nullable one on loans, issue_date is not nullable and defaults to NOW(), and loans.status carries a check constraint allowing active, returned and overdue. The same pattern appears on fines, where paid_at stays NULL until the fine is paid.