VACUUM, and Why This Engine Rarely Needs It
Space inside the file is already reused; what VACUUM does is give it back to the operating system
Add the VACUUM statement. With N pages in the file and F of them free, it moves every page in use at N − F or above into the free pages below, fixes every number that named a moved page, empties the free list and shortens the file to N − F pages. It prints a report of the pages moved and released. SQL results never change.
- Two meanings of vacuum: sweeping dead rows, and shrinking the file
- This engine leaves no dead rows: deleted space is reused without a sweep
- Only the end of a file can be given back, so pages in use must move down first
- Moving a page means finding every number that names it
- VACUUM is the one operation allowed to move a tree's root, and it updates the catalog
Why this stage exists
"Vacuum" means two different jobs, and databases disagree about which one the word names.
The first job is sweeping dead rows. In PostgreSQL, an UPDATE or a DELETE does not remove the old row: it marks it dead and leaves it in the table, because another transaction may still need to see it. Dead rows pile up, and VACUUM has to come through and reclaim their space. Skip it, and tables grow forever.
This engine never has that job. A DELETE removes the cell from its leaf at once. A leaf compacts itself when an insert needs the room, and since borrow and merge, a node that falls below a quarter full is merged away, its page goes to the free list, and the next allocation takes it back. Delete half a table and insert it again, and the file does not grow by a page: the first test of this stage checks exactly that, five times over. A leaf that is only half empty stays as it is: its free bytes wait for the next insert that lands in it, and VACUUM does not repack it either.
The second job is giving space back to the operating system. The free list keeps pages for reuse, but they still sit in the file. Delete most of a large table and the file stays large: nothing in the engine so far ever makes it shorter. That is the job this VACUUM does, the one SQLite calls VACUUM and MySQL calls OPTIMIZE TABLE.
Two engines, two answers. PostgreSQL keeps old row versions in the table itself, so VACUUM must sweep them, and a background autovacuum runs it for you. InnoDB updates rows in place and keeps old versions in a separate undo log; a DELETE only marks the row, and a background purge thread removes it soon after, so nobody runs a sweep by hand. Shrinking the file is a separate, rarer job. This engine follows InnoDB. The multi-version half of the story, where old versions are needed at all, comes with Sessions and Concurrency.
What changes: only the end can go
A file can only be cut at its end. If page 9 of a 12-page file is free, there is no way to give back that page alone. So VACUUM first makes the end of the file free: every page in use past where the file will end moves into a free page before it.
With N pages and F of them free, the file will end at N − F. The pages in use at N − F or above are exactly as many as the free pages below it, so they pair up: the highest page in use goes to the lowest free page, the next highest to the next lowest. After the moves, every page past the end is free, and the file is cut there.
Here is a table of 30 rows after deleting rows 1 to 12. Borrows and merges freed pages 4, 5 and 6 of a 12-page file, so the file will keep 9 pages, and pages 9, 10 and 11 are in use past that end.
What changes: everything that names a page
Moving a page is the easy half: copy its bytes. The hard half is that other pages hold its number, and every one of them must now hold the new number, or the tree points at a free page. These are all the places a page number lives:
| What names the page | Where the number is |
|---|---|
| the catalog's root | the header's root field |
| a table's root | its catalog row's root |
| a node | its parent's child entry, or the parent's right child |
| an internal node | the parent field of each of its children |
| a leaf | the link of the leaf before it |
| an overflow chain's first page | the last four bytes of the cell whose row it continues |
| any other overflow page | the next field of the overflow page before it |
Find them all before moving anything, by walking from the header: the catalog's tree, each table's tree from its catalog row, and each cell's chain. A page in use that the walk never reaches, such as one only pager write touched, still moves, with its bytes; nothing needs fixing for it.
Order matters in one way. A place that names a page may sit on a page that moves too, before or after. Keep track of where each page is now, and fix the number where its holder is at that moment. A holder that has not moved yet carries the fixed number with it when it does.
The one exception. Since the first stage of the B+Tree, a tree's root has never moved: a split keeps the root on its page, and so does a collapse. VACUUM is the only operation allowed to break that rule. A root past the end moves like any other page, and its catalog row is updated, so catalog list shows the new page. The catalog's own root, on page 1, never moves, since nothing below it can be free.
The algorithm in words
Plan the moves
Before any page is touched.
- 1.Count the pages, N, and the free pages, F. If F is 0, report zeros and change nothing.
- 2.The file will end at N − F. The pages in use at N − F or above have to move; there are as many of them as free pages below N − F.
- 3.Pair them up: the highest page in use with the lowest free page below the end, the next highest with the next lowest, and so on.
Find what names each page
Every place in the file that holds a page number.
- 1.Start at the header: its root field names the catalog's root. Walk the catalog's tree, and each catalog row's root names a table's root; walk each table's tree.
- 2.In an internal node, each child entry names a child, and each child's parent field names the node.
- 3.In a leaf, the link names the next leaf. A cell whose row has an overflow chain names the chain's first page, and each overflow page names the next.
- 4.A page in use that nothing names, one only pager write touched, still moves, with its bytes.
Move, fix and shorten
In the order planned.
- 1.Copy the page's bytes to its new number. Then change every place that named the old number to name the new one, wherever that place is now, since it may itself have moved already.
- 2.When every page has moved, set the free list head and count in the header to 0.
- 3.Drop the pages past the new end from the buffer pool without writing them, and set the page count to N − F. When the session ends, the file is N − F pages long.
- 4.Print the success line and the report: pages moved, pages released (F) and bytes released (F × 4096).
Output contract for this stage
The full definition is on the output contract page, section 30.
| Input | Output |
|---|---|
VACUUM; | Vacuum statement executed successfully, then [VACUUM] pages_moved=<m> pages_released=<F> bytes_released=<F × 4096> |
VACUUM; with no free page | pages_moved=0 pages_released=0 bytes_released=0, and nothing changes |
tokenize vacuum; | [KEYWORD_VACUUM - vacuum], [SYMBOL - ;] |
ast vacuum; | Statement: VACUUM |
explain vacuum; | [PLAN] vacuum, [PLAN] └── Vacuum, and nothing changes |
| after VACUUM | db freelist prints pages=0 chain=[]; pager status shows N − F pages |
pages_moved is the number of pages in use at N − F or above. SQL results after VACUUM are the same as before it; only page numbers change.
What you should see when done
With users holding rows 1 to 30 of 502 bytes, from an earlier session, as in the diagram above:
Welcome to droid!>delete from users where id < 13;Delete statement executed successfully (12 rows)[EXEC] rows_examined=12>db freelist[DB] Free list: pages=3 chain=[6,5,4]>pager status[PAGER] Status: total_pages=12 cached=12>vacuum;Vacuum statement executed successfully[VACUUM] pages_moved=3 pages_released=3 bytes_released=12288>db freelist[DB] Free list: pages=0 chain=[]>pager status[PAGER] Status: total_pages=9 cached=9>.exitexiting.. good bye!
Things to notice. The free list lists 6, 5, 4, but VACUUM fills the lowest first, 4, then 5, then 6: the list's order is the order of reuse, not the order of the file. And the file went from 49,152 bytes to 36,864, with the same 18 rows in it.
Common bugs in this stage
- Moving a page and fixing nothing. The tree then points at a page past the end, which the file no longer has. Every number in the table above must be fixed.
- Forgetting the parent fields. A moved internal node's children still say their parent is the old page. Nothing fails at once; the next split or merge that follows a parent field goes wrong.
- Forgetting the leaf link. Full scans skip rows, or read a page past the end, while lookups still work.
- Fixing a number on the old copy of a page. If the holder already moved, the fix must go to its new page.
- Leaving the catalog's root alone. A moved table root that the catalog does not name makes the table look empty, or broken, after the next restart.
- Writing pages past the end when the session ends. They are gone: drop them from the pool without writing, or the file grows back.
- Shrinking the file and not the free list. The header must say head 0, count 0, or the next allocation hands out a page past the end.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: an array of
(page, offset)pairs per page number, built in one walk, makes the fixes a loop;ftruncateshortens the file. - C++: a
std::unordered_map<uint32_t, std::vector<Ref>>for what names each page, andstd::filesystem::resize_fileto shorten it. - Rust: a
HashMap<u32, Vec<(u32, usize)>>for the references, andFile::set_lento shorten the file. - Zig: a
std.AutoHashMap(u32, std.ArrayList(Ref))for the references, andfile.setEndPosto shorten the file.
Checklist
One line per thing the tests check. The tags name the test cases.
- Deleting rows and inserting them again never grows the file, and VACUUM then gives back what is left freechurn-no-growth
- VACUUM releases the free pages: the file ends at the last page in use, the free list is emptyvacuum-releases-free-pages
- VACUUM moves pages in use past the end down, highest first to the lowest free page, and fixes parents, children and leaf linksvacuum-moves-pages-down
- VACUUM may move a table's root, and the catalog names the new pagevacuum-moves-root
- VACUUM moves overflow pages and fixes the cell and the links that name themvacuum-moves-overflow-chain
- A page that nothing points at moves with its bytesvacuum-moves-unreferenced-page
- VACUUM with no free pages reports zeros and changes nothingvacuum-clean-noop
- The file VACUUM leaves is whole after a restart, and the next page allocated is at its endvacuum-persists
- SQL results after VACUUM are the same as before itvacuum-preserves-data
- Every table keeps its rows and its root, and takes more rowsvacuum-two-tables
- vacuum is a keywordtokenize-vacuum
- ast prints Statement: VACUUMast-vacuum
- explain prints a Vacuum plan and changes nothingexplain-vacuum