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.
"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.
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.
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.
Before any page is touched.
Every place in the file that holds a page number.
In the order planned.
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.
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.
The page above is the same for every language. These are the only language-specific hints.
(page, offset) pairs per page number, built in one walk, makes the fixes a loop; ftruncate shortens the file.std::unordered_map<uint32_t, std::vector<Ref>> for what names each page, and std::filesystem::resize_file to shorten it.HashMap<u32, Vec<(u32, usize)>> for the references, and File::set_len to shorten the file.std.AutoHashMap(u32, std.ArrayList(Ref)) for the references, and file.setEndPos to shorten the file.