---
title: 'System Catalog'
source: 'https://academia.sh/en/courses/database-administration/system-catalog'
course: 'Relational Database Administration'
language: en
updated: '2026-08-23T07:00:41+00:00'
license: 'CC BY-SA 4.0'
---

# System Catalog

Storing schema definitions in queryable tables, turning inventory and administrative rules into queries, measuring schema drift between two copies, and why the catalog is read but not written.

Every question asked so far in this course has been answered with a number: page count,
fill, frame count, hit rate. The source of these numbers is the engine's own records,
and those records do not sit behind a special interface. The engine keeps schema
information in ordinary tables and hands it out through ordinary queries. This entire
set of tables is called the **system catalog**.

The catalog being queryable takes administration out of being a documentation task.
Which columns which tables carry, which indexes exist, which constraints are defined —
none of this sits written down in a hand-maintained list; it is written in the database
itself, and that list is always current, because the statement that makes the change
also updates it.

## The Catalog Is an Ordinary Table

The block below sets up the library schema. The schema is familiar from earlier
courses; indexes, a view, and a value check have been added to it here.

```sh
rm -f library.db
sqlite3 library.db <<'SQL'
CREATE TABLE branch (
  branch_id INTEGER PRIMARY KEY,
  name    TEXT NOT NULL,
  city    TEXT NOT NULL
);
CREATE TABLE member (
  member_id         INTEGER PRIMARY KEY,
  name              TEXT NOT NULL,
  email             TEXT UNIQUE,
  registration_date TEXT NOT NULL,
  status            TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended','closed'))
);
CREATE TABLE book (
  book_id    INTEGER PRIMARY KEY,
  title      TEXT NOT NULL,
  author     TEXT NOT NULL,
  isbn       TEXT UNIQUE,
  branch_id  INTEGER NOT NULL REFERENCES branch(branch_id)
);
CREATE TABLE loan (
  loan_id     INTEGER PRIMARY KEY,
  member_id   INTEGER NOT NULL REFERENCES member(member_id),
  book_id     INTEGER NOT NULL REFERENCES book(book_id),
  pickup_date TEXT NOT NULL,
  return_date TEXT
);
CREATE INDEX loan_member ON loan(member_id);
CREATE INDEX loan_book_date ON loan(book_id, pickup_date);
CREATE VIEW open_loan AS SELECT * FROM loan WHERE return_date IS NULL;
INSERT INTO branch VALUES (1,'Central','Ankara'),(2,'Bahcelievler','Ankara');
INSERT INTO member (member_id,name,email,registration_date) VALUES (1,'Alice','alice@example.test','2023-02-14');
INSERT INTO book VALUES (1,'Handbook of Astronomy','Y. Sterling','978-0201896831',1);
INSERT INTO loan VALUES (1,1,1,'2025-06-01',NULL);
SQL
```

The list of schema objects can be pulled with a single query.

```sh
sqlite3 library.db <<'SQL'
.headers on
.mode column
SELECT type, name, tbl_name FROM sqlite_schema ORDER BY type, name;
SQL
```

```
type   name                       tbl_name 
-----  -------------------------  ---------
index  loan_book_date             loan     
index  loan_member                loan     
index  sqlite_autoindex_book_1    book     
index  sqlite_autoindex_member_1  member   
table  book                       book     
table  branch                     branch   
table  loan                       loan     
table  member                     member   
view   open_loan                  open_loan
```

Two rows in the list stand out as never having been written: the `sqlite_autoindex_`
prefixed indexes. No one defining the schema wrote these; the engine created them itself
to enforce a uniqueness constraint. The catalog shows not just what was written, but the
structure **the engine built**. This distinction is useful in administration: a column
being unique means an index cost in the background, and that cost is visible right here.

The convention of separating internal object names with a prefix is specific to the
engine; what does not change is that internal objects also live in the catalog.

## Extracting an Inventory with a Query

Because the catalog is queryable, every question about the schema can be turned into a
query. The query below produces a per-table column and constraint count.

```sh
sqlite3 library.db <<'SQL'
.headers on
.mode column
-- Schema inventory: column and constraint counts per table.
SELECT m.name AS table_name, count(*) AS columns, sum(ti."notnull") AS not_null,
       sum(ti.pk > 0) AS primary_key
FROM sqlite_schema m JOIN pragma_table_info(m.name) ti
WHERE m.type='table' GROUP BY m.name ORDER BY m.name;
SQL
```

```
table_name  columns  not_null  primary_key
----------  -------  --------  -----------
book        5        3         1          
branch      3        2         1          
loan        5        3         1          
member      5        3         1          
```

This table is the starting point of a schema design discussion: how many columns each
table carries, how many are required, whether any table lacks a primary key. In a
hand-maintained document, these three questions each need their own section, and all
three go stale; pulled from the catalog, the query gives a current answer every time it
runs.

## Audits Written Against the Catalog

The catalog's real value is that **rules** can be written as queries. The block below
applies, in advance, a rule that will come up in the Indexes and Partitioning topic:
every column carrying a foreign key should have its own index, otherwise deletes and
updates on the parent table have to scan the child table from end to end.

```sh
sqlite3 library.db <<'SQL'
.headers on
.mode column
-- 1. Foreign keys left without an index: raises the cost of deletes and updates.
SELECT m.name AS table_name, f."from" AS column_name, f."table" AS target
FROM sqlite_schema m
JOIN pragma_foreign_key_list(m.name) f
WHERE m.type = 'table'
  AND NOT EXISTS (
    SELECT 1 FROM pragma_index_list(m.name) il
    JOIN pragma_index_info(il.name) ii
    WHERE ii.seqno = 0 AND ii.name = f."from"
  );
SQL
```

```
table_name  column_name  target
----------  -----------  ------
book        branch_id    branch
```

The audit produced a single finding: the book table's reference to branch is
unindexed. The loan table's two references are covered — one by its own index, the
other because it is the first column of a composite index. That a composite index's
**first** column, and only that column, does this job is the counterpart here of the
rule established in the SQL Performance topic.

This audit's advantage over a hand-maintained list is threefold. When a new table is
added, the audit covers it automatically. When an index is dropped, the finding comes
back automatically. And the audit reads what the engine actually knows, not what a
person wrote — when the two diverge, the divergence becomes visible.

Other audits can be written with the same pattern: tables without a primary key, indexes
that are never used, duplicate indexes sharing the same column list, status columns with
no value check. Each one is an administrative rule in the form of a query.

## Measuring Schema Drift

Because the catalog is queryable, two databases' schemas can be compared. This is the
operation most often needed during version upgrades and cross-environment consistency
checks.

```sh
# The difference between two schemas: are the development and production copies the same?
sqlite3 production.db <<'SQL'
CREATE TABLE branch (branch_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL);
CREATE TABLE member (member_id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE,
                  registration_date TEXT NOT NULL);
CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL,
                    isbn TEXT UNIQUE, branch_id INTEGER NOT NULL REFERENCES branch(branch_id));
CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, member_id INTEGER NOT NULL REFERENCES member(member_id),
                    book_id INTEGER NOT NULL REFERENCES book(book_id), pickup_date TEXT NOT NULL,
                    return_date TEXT);
CREATE INDEX loan_member ON loan(member_id);
SQL

compare() {
  sqlite3 "$1" "SELECT type||' '||name||' ('||coalesce((SELECT group_concat(ti.name||':'||ti.type, ', ')
                  FROM pragma_table_info(m.name) ti), '')||')'
                FROM sqlite_schema m WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name;"
}
compare library.db    > development.list
compare production.db > production.list
echo "--- only in development ---"
comm -23 development.list production.list
echo "--- only in production ---"
comm -13 development.list production.list
```

```
--- only in development ---
index loan_book_date ()
table member (member_id:INTEGER, name:TEXT, email:TEXT, registration_date:TEXT, status:TEXT)
view open_loan (loan_id:INTEGER, member_id:INTEGER, book_id:INTEGER, pickup_date:TEXT, return_date:TEXT)
--- only in production ---
table member (member_id:INTEGER, name:TEXT, email:TEXT, registration_date:TEXT)
```

The diff shows three items. An index and a view exist only in the development copy. The
member table exists on both sides, but it is **not the same**: a status column has
been added in development and is missing in production. Including the column list in
the comparison makes this difference visible; if only object names had been compared,
the two tables would have counted as equal.

The direction of the drift is also information. A column present in development but
absent in production means a migration that has not yet been applied. An object present
in production but absent in development is more concerning: it points to a change
written directly to production, with no counterpart in version control.

## Writing to the Catalog

The catalog is read; it is **not written by hand**. Schema changes are made with data
definition statements, and the engine takes on updating the catalog. Writing directly
to catalog tables — even where it is technically possible in some engines — breaks the
engine's internal consistency: the physical layout in the data files and the definition
in the catalog diverge, and that divergence usually shows up later as a hard-to-diagnose
error on some subsequent read.

The practical form of the rule is this: migrations are written as statements and kept
in version control; the catalog is read only to **verify** whether those migrations
have been applied.

## Summary

- The system catalog is the set of tables that hold the schema's definitions and can be
  read with ordinary queries; because the statement that makes a change also updates it,
  the catalog is always current.
- The catalog shows not just what was written but the objects the engine builds on its
  own: the index behind a uniqueness constraint shows up in the list.
- Every administrative rule about the schema can be turned into a query; the unindexed
  foreign key audit produced a single finding in the model and covers new tables
  automatically.
- Comparing two copies' schemas together with their column lists makes drift visible;
  comparing only object names would count a changed table as equal.
- The catalog is read, not written by hand: schema changes are made with statements, and
  the catalog is queried to verify that change was applied.

## Next Step

The engine's internals close out with this lesson: processes, pages, the log,
checkpoints, version chains, cleanup, and the catalog that records all of it. One
finding was left open in this section — the unindexed foreign key. The next topic opens
the area that finding belongs to: how index types differ from each other, why column
order changes the outcome in a composite index, when partial and covering indexes pay
off, and which problem splitting a table into partitions solves and which it creates.
