~learn.

LLD 1212 min+60 XP

Design a Library Management System.

Design a library's catalogue, loans, reservations and fines, then run a month

5 code

Lesson · 12 min

Two members want the same novel, there are two copies, and a third member is waiting. The design question is never "how do I store a book". It's "which book do you mean: the title, or the copy in your hand?"

The library system is a staple of machine-coding rounds and of low-level design courses because it looks like CRUD and isn't. A list of books with an is_borrowed flag works until the interviewer says "there are three copies", then "a member can reserve a book that's out", then "the reserved copy should wait for them for three days". Each of those breaks a model that confused a title with a copy, or that let a returned book drift back to the open shelf when someone was waiting for it.

This lesson builds titles and copies, loans, the reservation queue and hold shelf, fines as a strategy, an inverted index for search, and notifications behind an interface. Then it runs a month of a small library: late returns, an uncollected hold, a fine cap, and a race for the last copy.

How the round runs#

Interviewer

"Design a library management system. Members borrow and return books, reserve books that are out, and pay fines for late returns. Librarians search the catalogue. Members get notified when a reserved book is ready. Show me working code with a demo. 90 minutes."

A good 90 minutes
  • 0–10 min —
    clarify: titles vs copies, loan rules, fines, reservations, renewals, search, notifications
  • 10–25 min —
    the classes: catalogue, copies, members, loans; the copy's status
  • 25–50 min —
    borrow, return and reserve, with the hold shelf
  • 50–65 min —
    fines as a strategy, search as an inverted index, notifications as an observer
  • 65–80 min —
    a month in the demo: late returns, an uncollected hold, a race
  • 80–90 min —
    follow-ups: branches, e-books, membership tiers, lost books

Requirements and clarifying questions#

You ask They answer What it changes
Is a "book" a title or a copy? Both: many copies per title. Book (catalogue) and BookCopy (barcode, status)
Loan length and limit? 14 days; up to 5 books per member. due_on; a limit check in borrow
Fines? ₹10 a day late, capped at ₹300. Owing ₹100 or more blocks borrowing. A FinePolicy strategy; a check in borrow
Reservations? Only when every copy is out; first come, first served. A FIFO queue per title
A reserved title comes back? Held for the next member for 3 days, then passed on. An ON_HOLD status; expire_holds()
Renewals? Once, and not while someone is waiting. A renewal count and a queue check
Search? By words in the title, author or subject. An inverted index
Notifications? When a hold is ready or expires, and the day before a due date. A Notifier interface
Your turn · checkpoint 1not answered

Book or BookCopy: which one does a loan point to, and which does a reservation?

The library has two copies of Godan. Should Loan reference the title or the copy? And Reservation?

Think it through, or jot it down. Then compare.

One strong answer

A loan points at a copy: a member takes home one particular object with a barcode, returns that object, and is fined for that object. A reservation points at a title: Rohan wants Godan, and whichever copy comes back first will do. If you have only one Book class with a status, two copies are two Book objects with the same title and author: search shows duplicates, and "reserve Godan" has nothing to point at. The asymmetry, loans by copy and reservations by title, is the heart of the model.

The classes#

DiagramLibrary: the classes
%% title: Library: the classes
classDiagram
  class Book {
    +book_id str
    +title str
    +authors tuple
    +subjects tuple
  }
  class BookCopy {
    +barcode str
    +book_id str
    +status CopyStatus
    +held_for str
    +hold_until date
  }
  class CopyStatus {
    <<enumeration>>
    AVAILABLE
    ON_LOAN
    ON_HOLD
  }
  class Member {
    +member_id str
    +name str
    +fines_due int
  }
  class Loan {
    +barcode str
    +member_id str
    +due_on date
    +renewals int
  }
  class FinePolicy {
    <<interface>>
    +fine(days_late) int
  }
  class Notifier {
    <<interface>>
    +send(member, message)
  }
  class Library {
    +borrow(member, book, today) Loan
    +return_copy(barcode, today) int
    +reserve(member, book) int
    +renew(barcode, today) Loan
    +expire_holds(today)
    +search(query, field) list
  }
  Book "1" *-- "many" BookCopy
  BookCopy --> CopyStatus
  Loan --> BookCopy
  Loan --> Member
  Library --> FinePolicy
  Library --> Notifier
  Library --> CatalogIndex
  FinePolicy <|.. PerDayFine
  Notifier <|.. ConsoleNotifier

A copy has a small life cycle: AVAILABLE → ON_LOAN → AVAILABLE, or, when someone is waiting, ON_LOAN → ON_HOLD → ON_LOAN (the waiting member collects it) or ON_HOLD → ON_HOLD for the next member when a hold expires. A copy on hold is not available: the open-shelf borrow path never sees it.

The interfaces#

pythonBook · 57 lines
# library.py, part 1: titles, copies, members and loans
from __future__ import annotations

import itertools
import re
import threading
from abc import ABC, abstractmethod
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import date, timedelta
from enum import Enum


@dataclass(frozen=True)
class Book:  # a title in the catalogue; in practice keyed by ISBN
    book_id: str
    title: str
    authors: tuple[str, ...]
    subjects: tuple[str, ...]


class CopyStatus(Enum):
    AVAILABLE = "available"
    ON_LOAN = "on loan"
    ON_HOLD = "on hold"  # on the hold shelf, waiting for one member


@dataclass
class BookCopy:  # one physical copy with a barcode
    barcode: str
    book_id: str
    status: CopyStatus = CopyStatus.AVAILABLE
    held_for: str | None = None
    hold_until: date | None = None


@dataclass
class Member:
    member_id: str
    name: str
    fines_due: int = 0  # rupees


@dataclass
class Loan:
    loan_id: str
    barcode: str
    member_id: str
    borrowed_on: date
    due_on: date
    renewals: int = 0


class LibraryError(Exception): ...


class NoCopyAvailable(LibraryError): ...

The three things that vary: how fines are computed, how members are told, and how search matches. Fines and notifications are strategies the library is given; search is an inverted index, a map from each word to the titles that contain it, kept per field so "Tagore" as an author doesn't match a book about Tagore.

pythonFinePolicy · 46 lines
# part 2: fines, notifications and search, each behind its own small interface
class FinePolicy(ABC):
    @abstractmethod
    def fine(self, days_late: int) -> int: ...


class PerDayFine(FinePolicy):
    def __init__(self, per_day: int = 10, cap: int = 300) -> None:
        self.per_day, self.cap = per_day, cap

    def fine(self, days_late: int) -> int:
        return min(max(days_late, 0) * self.per_day, self.cap)


class Notifier(ABC):
    @abstractmethod
    def send(self, member: Member, message: str) -> None: ...


class ConsoleNotifier(Notifier):  # stands in for email or SMS
    def send(self, member: Member, message: str) -> None:
        print(f"  [to {member.name}] {message}")


def words(text: str) -> set[str]:
    return set(re.findall(r"[a-z0-9]+", text.lower()))


class CatalogIndex:
    FIELDS = ("title", "author", "subject")

    def __init__(self) -> None:
        self._index: dict[str, dict[str, set[str]]] = {f: defaultdict(set) for f in self.FIELDS}

    def add(self, book: Book) -> None:
        texts = {"title": book.title, "author": " ".join(book.authors), "subject": " ".join(book.subjects)}
        for f, text in texts.items():
            for w in words(text):
                self._index[f][w].add(book.book_id)

    def search(self, query: str, field: str | None = None) -> set[str]:
        hits: set[str] | None = None
        for w in words(query):  # every word must match (AND), in the field asked for or any field
            ids = set().union(*(self._index[f].get(w, set()) for f in ([field] if field else self.FIELDS)))
            hits = ids if hits is None else hits & ids
        return hits or set()

The patterns, and why#

Pattern Where Why it earns its place
Catalogue vs item Book and BookCopy One title, many copies. Loans point at copies, reservations at titles.
Strategy FinePolicy Fines change by membership tier, by item type, during an amnesty week. Each is a class; return_copy doesn't change.
Observer Notifier Email, SMS and an app push subscribe to the same events. The library never knows how a member is reached.
State (as an enum) CopyStatus Each operation checks the copy's status first; only some transitions are legal.
Inverted index CatalogIndex Search is a dictionary lookup per word, not a scan of every title.
Facade Library The desk, the website and the nightly job call a handful of methods and never touch a copy directly.

Concurrency and edge cases#

Two desks and a website call the library at once. Three races matter. Two members borrow the last copy: finding an AVAILABLE copy and marking it ON_LOAN must be one step. A return races a walk-in borrower: the returned copy must go to the hold shelf for the next member in the queue in the same step as the return, or a walk-in grabs it in between. A hold expires while its member is at the desk: expiry and collection must not interleave. One library lock around each operation handles all three; at scale it becomes a lock per title, since operations on different titles never conflict. Notifications are sent after the lock is released, so a slow SMS gateway can't stall the desks.

Edge cases
  • Borrowing a title whose only free copy is on hold for someone else: refused; that copy is spoken for
  • Reserving while a copy is on the shelf: refused; borrow it instead
  • Reserving twice, or reserving a title you already have on loan: refused
  • An uncollected hold: after 3 days it goes to the next in line, or back to the shelf if nobody is waiting
  • Renewing while others are waiting: refused, or the queue never moves
  • Returning a barcode that isn't on loan: an error, not a silent no-op
  • Very late returns: the fine stops at the cap; owing ₹100 or more blocks borrowing until paid

The code#

The library. Every operation takes the lock, collects the messages it wants to send, and sends them after releasing it.

pythonLibrary · 99 lines
# part 3: the library: borrow, return, reserve, renew, holds, reminders
class Library:
    def __init__(self, fines: FinePolicy, notifier: Notifier, loan_days: int = 14, max_loans: int = 5, hold_days: int = 3, fine_block: int = 100) -> None:
        self.fines, self.notifier = fines, notifier
        self.loan_days, self.max_loans, self.hold_days, self.fine_block = loan_days, max_loans, hold_days, fine_block
        self.books: dict[str, Book] = {}
        self.copies: dict[str, BookCopy] = {}
        self.members: dict[str, Member] = {}
        self.loans: dict[str, Loan] = {}  # barcode -> the active loan
        self.queue: dict[str, deque[str]] = defaultdict(deque)  # book_id -> member ids waiting
        self.index = CatalogIndex()
        self._ids = itertools.count(1)
        self._lock = threading.Lock()

    def add_book(self, book: Book, barcodes: list[str]) -> None:
        with self._lock:
            self.books[book.book_id] = book
            self.copies.update({b: BookCopy(b, book.book_id) for b in barcodes})
            self.index.add(book)

    def search(self, query: str, field: str | None = None) -> list[str]:
        return sorted(self.books[i].title for i in self.index.search(query, field))

    def borrow(self, member_id: str, book_id: str, today: date) -> Loan:
        with self._lock:
            m = self.members[member_id]
            if m.fines_due >= self.fine_block:
                raise LibraryError(f"{m.name} owes ₹{m.fines_due}: pay first")
            mine = [l for l in self.loans.values() if l.member_id == member_id]
            if len(mine) >= self.max_loans or any(self.copies[l.barcode].book_id == book_id for l in mine):
                raise LibraryError("loan limit reached, or already borrowed")
            ours = [c for c in self.copies.values() if c.book_id == book_id]
            copy = next((c for c in ours if c.status is CopyStatus.ON_HOLD and c.held_for == member_id), None) or next(
                (c for c in ours if c.status is CopyStatus.AVAILABLE), None
            )
            if copy is None:
                raise NoCopyAvailable(f"no free copy of {self.books[book_id].title}")
            copy.status, copy.held_for, copy.hold_until = CopyStatus.ON_LOAN, None, None
            loan = Loan(f"L{next(self._ids)}", copy.barcode, member_id, today, today + timedelta(days=self.loan_days))
            self.loans[copy.barcode] = loan
            return loan

    def return_copy(self, barcode: str, today: date) -> int:
        with self._lock:
            loan = self.loans.pop(barcode, None)
            if loan is None:
                raise LibraryError(f"{barcode} is not on loan")
            m, copy = self.members[loan.member_id], self.copies[barcode]
            fine = self.fines.fine((today - loan.due_on).days)
            m.fines_due += fine
            copy.status = CopyStatus.AVAILABLE
            outbox = self._pass_on(copy, today)  # same step as the return: no walk-in can grab it first
        self._send(outbox)
        return fine

    def reserve(self, member_id: str, book_id: str) -> int:
        with self._lock:
            ours = [c for c in self.copies.values() if c.book_id == book_id]
            if any(c.status is CopyStatus.AVAILABLE for c in ours):
                raise LibraryError("a copy is on the shelf: borrow it")
            if member_id in self.queue[book_id] or any(self.loans.get(c.barcode, None) and self.loans[c.barcode].member_id == member_id for c in ours):
                raise LibraryError("already reserved or borrowed")
            self.queue[book_id].append(member_id)
            return len(self.queue[book_id])

    def renew(self, barcode: str, today: date) -> Loan:
        with self._lock:
            loan = self.loans[barcode]
            if loan.renewals >= 1 or self.queue[self.copies[barcode].book_id] or today > loan.due_on:
                raise LibraryError("can't renew: renewed already, overdue, or someone is waiting")
            loan.renewals, loan.due_on = loan.renewals + 1, loan.due_on + timedelta(days=self.loan_days)
            return loan

    def expire_holds(self, today: date) -> None:  # a nightly job
        outbox = []
        with self._lock:
            for c in self.copies.values():
                if c.status is CopyStatus.ON_HOLD and c.hold_until < today:
                    outbox.append((c.held_for, f"your hold on {self.books[c.book_id].title} has expired"))
                    c.status, c.held_for, c.hold_until = CopyStatus.AVAILABLE, None, None
                    outbox += self._pass_on(c, today)
        self._send(outbox)

    def remind(self, today: date) -> None:  # a daily job
        with self._lock:
            outbox = [(l.member_id, f"{self.books[self.copies[l.barcode].book_id].title} is due tomorrow") for l in self.loans.values() if l.due_on == today + timedelta(days=1)]
        self._send(outbox)

    def _pass_on(self, copy: BookCopy, today: date) -> list[tuple[str, str]]:
        q = self.queue[copy.book_id]
        if not q:
            return []
        member_id = q.popleft()
        copy.status, copy.held_for, copy.hold_until = CopyStatus.ON_HOLD, member_id, today + timedelta(days=self.hold_days)
        return [(member_id, f"{self.books[copy.book_id].title} is held for you until {copy.hold_until:%d %b}")]

    def _send(self, outbox: list[tuple[str, str]]) -> None:
        for member_id, message in outbox:  # outside the lock: a slow gateway can't stall the desks
            self.notifier.send(self.members[member_id], message)

A month in a small library:

pythongrab · 53 lines
# part 4: a month: search, reservations, a late return, an uncollected hold, a fine cap, a race
if __name__ == "__main__":
    lib = Library(PerDayFine(per_day=10, cap=300), ConsoleNotifier())
    lib.add_book(Book("BK1", "Godan", ("Premchand",), ("novel", "hindi literature")), ["G1", "G2"])
    lib.add_book(Book("BK2", "Gitanjali", ("Rabindranath Tagore",), ("poetry",)), ["T1"])
    lib.add_book(Book("BK3", "The Discovery of India", ("Jawaharlal Nehru",), ("history",)), ["D1"])
    lib.add_book(Book("BK4", "Malgudi Days", ("R. K. Narayan",), ("short stories", "fiction")), ["M1"])
    for mid, name in [("m1", "Ishaan"), ("m2", "Zoya"), ("m3", "Rohan"), ("m4", "Tara")]:
        lib.members[mid] = Member(mid, name)
    print("search tagore:", lib.search("tagore"), "| india:", lib.search("india"), "| stories, by subject:", lib.search("stories", "subject"))

    d = date(2026, 11, 2)
    lib.borrow("m1", "BK1", d)
    zoya = lib.borrow("m2", "BK1", d)
    print("Zoya has", zoya.barcode, "due", f"{zoya.due_on:%d %b}")
    try:
        lib.borrow("m3", "BK1", d)
    except NoCopyAvailable as e:
        print("Rohan:", e, "| queue position", lib.reserve("m3", "BK1"), "| Tara: position", lib.reserve("m4", "BK1"))
    try:
        lib.renew("G2", d + timedelta(days=5))
    except LibraryError as e:
        print("Zoya renews:", e)

    lib.remind(date(2026, 11, 15))
    print("Zoya returns 4 days late, fine ₹", lib.return_copy("G2", date(2026, 11, 20)), sep="")
    try:
        lib.borrow("m4", "BK1", date(2026, 11, 21))
    except NoCopyAvailable as e:
        print("Tara, too early:", e)
    lib.expire_holds(date(2026, 11, 24))  # Rohan never came
    print("Tara collects:", lib.borrow("m4", "BK1", date(2026, 11, 25)).barcode)

    print("Ishaan returns 44 days late, fine ₹", lib.return_copy("G1", date(2026, 12, 30)), sep="")
    try:
        lib.borrow("m1", "BK4", date(2026, 12, 30))
    except LibraryError as e:
        print("Ishaan:", e)

    results: list[str] = []

    def grab(member_id: str) -> None:
        try:
            results.append(lib.borrow(member_id, "BK2", date(2026, 12, 31)).barcode)
        except NoCopyAvailable:
            results.append("none left")

    ts = [threading.Thread(target=grab, args=(m,)) for m in ("m2", "m3")]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    print("two members, one Gitanjali:", sorted(results))
text15 lines
search tagore: ['Gitanjali'] | india: ['The Discovery of India'] | stories, by subject: ['Malgudi Days']
Zoya has G2 due 16 Nov
Rohan: no free copy of Godan | queue position 1 | Tara: position 2
Zoya renews: can't renew: renewed already, overdue, or someone is waiting
  [to Ishaan] Godan is due tomorrow
  [to Zoya] Godan is due tomorrow
  [to Rohan] Godan is held for you until 23 Nov
Zoya returns 4 days late, fine ₹40
Tara, too early: no free copy of Godan
  [to Rohan] your hold on Godan has expired
  [to Tara] Godan is held for you until 27 Nov
Tara collects: G2
Ishaan returns 44 days late, fine ₹300
Ishaan: Ishaan owes ₹300: pay first
two members, one Gitanjali: ['T1', 'none left']

Search matches words in the field asked for: "stories" finds Malgudi Days by subject. Both copies of Godan go out on 2 November, so Rohan and Tara queue, and Zoya can't renew while they wait. The reminder reaches both borrowers the day before the due date. Zoya's return four days late costs ₹40, and the copy goes straight to the hold shelf for Rohan until 23 November, so Tara can't take it at the desk. Rohan never comes: the nightly job expires his hold and passes the copy to Tara, who collects G2. Ishaan's 44 days would be ₹440, but the fine stops at ₹300, and owing that much blocks his next loan. Two members reach for the only Gitanjali at once; one gets T1.

Extending it: the follow-ups#

  • "We have five branches." A copy gets a branch; a hold names a pickup branch, and a returned copy may travel first, as an IN_TRANSIT status. The queue is still per title.
  • "Add e-books with a licence for three readers at once." A DigitalCopy with a count of licences instead of a barcode; borrowing takes a licence and expiry returns it automatically, so no fines.
  • "Members of different tiers." Loan days, the loan limit and the fine policy come from a MembershipPlan per member instead of the library's constructor.
  • "A book is lost or damaged." A LOST status and a charge from a replacement-cost policy; the loan closes and the copy leaves the count.
  • "Search should rank results and forgive typos." Count matching words per title for ranking; add a fuzzy matcher behind the same search().
✗ Pitfall

Common mistakes#

What costs candidates the round
  • One Book class with an is_borrowed flag, so copies duplicate titles and reservations have nothing to point at
  • Returning a reserved title straight to the shelf, so a walk-in takes it from the member who waited
  • Fines computed by a daily job that adds ₹10 to everyone overdue, which double-charges after a missed or repeated run; compute from the dates at return
  • Search by scanning every title with in, so "art" matches "Earth" and every search reads every title
  • Sending email inside the lock, so one slow gateway stops every desk
  • Renewals with no queue check, so a popular book is never returned
Quick check3 questions · +5 XP each

Library management

  1. Q1/3 A member reserves Godan. What should the reservation point to?

    Answer & explanation

    Answer: B

    Reservations are for a title; loans are for a copy. The first copy returned goes to the head of the title's queue.

  2. Q2/3 A copy of a reserved title is returned. What should happen in the same locked step?

    Answer & explanation

    Answer: B

    If the return and the hand-over to the queue are separate steps, a walk-in borrower can take the copy in between.

  3. Q3/3 Zoya's book was due on 16 November. She returns it on 20 November, and fines are ₹10 a day capped at ₹300. What is her fine?

    Answer & explanation

    Answer: C

    20 − 16 = 4 days late at ₹10 a day. The cap only matters past 30 days.

End of LLD 12 · Design a Library Management System
Finish LLD 12Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…