Chapter 03 · SQLite Schema Objects, ROWID, Keys, and Table Design

WITHOUT ROWID Tables and Composite Primary Keys

Compare ordinary rowid storage with primary-key-organized WITHOUT ROWID tables and learn when the alternative is structurally useful—and when measurement should decide.

Beginner80–100 minutesSide-by-side schema labSQLite 3.53.4 baselineLast reviewed: August 2026

Learning outcomes

A rowid table is a good default, especially when the table already has an INTEGER PRIMARY KEY. But some data is naturally identified by a text key or a composite key. SQLite's WITHOUT ROWID table form lets that declared primary key become the table's organizing key instead of keeping a separate hidden rowid.

01

Explain the storage motivation for WITHOUT ROWID using natural and composite keys.

02

Create equivalent rowid and WITHOUT ROWID tables and inspect their primary-key/index metadata.

03

Identify required PRIMARY KEY and NOT NULL behavior plus AUTOINCREMENT and rowid limitations.

04

Understand API differences such as last_insert_rowid(), incremental BLOB I/O, and update hooks at a conceptual level.

05

Choose WITHOUT ROWID only after workload and storage reasoning—not from unsupported performance claims.

The motivating case: identity is already composite

Suppose FieldNotes records which tags are assigned to each device. The logical identity of an assignment is the pair (device_id, tag). No separate numeric assignment ID is required by the requirements.

sql · ordinary composite-primary-key table
CREATE TABLE device_tag_rowid (    device_id INTEGER NOT NULL,    tag       TEXT NOT NULL,    PRIMARY KEY (device_id, tag));

Because this is an ordinary rowid table, SQLite still uses a hidden integer rowid as the table b-tree key. The declared composite PRIMARY KEY is enforced by separate uniqueness structure. Logically that is correct, but physically it means the table has identity information in two places: hidden rowid for storage lookup and the composite key for relational identity.

WITHOUT ROWID makes the declared primary key the storage key

Add WITHOUT ROWID after the closing parenthesis. The logical columns and queries remain familiar, but the table is organized using its declared PRIMARY KEY.

sql · primary-key-organized table
CREATE TABLE device_tag_wr (    device_id INTEGER NOT NULL,    tag       TEXT NOT NULL,    PRIMARY KEY (device_id, tag)) WITHOUT ROWID;

Official SQLite documentation describes WITHOUT ROWID as an optimization, not a new capability. It can reduce duplicated key storage or improve locality for some non-integer/composite-key tables. But wider keys also change b-tree fan-out, so the same choice can be neutral or harmful for another workload.

Ordinary rowid tableWITHOUT ROWID table
Hidden signed 64-bit rowid is the table key.Declared PRIMARY KEY is the table key.
Composite/text PRIMARY KEY usually needs separate uniqueness structure.No hidden rowid to duplicate identity.
INTEGER PRIMARY KEY has special rowid-alias behavior.No rowid exists, so INTEGER PRIMARY KEY is an ordinary primary-key column.
Primary-key NULL behavior has legacy SQLite quirks in non-STRICT rowid tables.Every PRIMARY KEY column is enforced NOT NULL.
last_insert_rowid() can reflect inserts.Inserts do not update last-insert-rowid state.

Inspect both table forms side by side

Do not infer table form from the column list alone. PRAGMA table_list reports a wr flag, and index metadata reveals different primary-key organization.

sql · metadata comparison
DROP TABLE IF EXISTS device_tag_rowid;DROP TABLE IF EXISTS device_tag_wr;CREATE TABLE device_tag_rowid (    device_id INTEGER NOT NULL,    tag       TEXT NOT NULL,    PRIMARY KEY (device_id, tag));CREATE TABLE device_tag_wr (    device_id INTEGER NOT NULL,    tag       TEXT NOT NULL,    PRIMARY KEY (device_id, tag)) WITHOUT ROWID;PRAGMA table_list('device_tag_rowid');PRAGMA table_list('device_tag_wr');PRAGMA index_list('device_tag_rowid');PRAGMA index_list('device_tag_wr');SELECT type, name, tbl_name, sqlFROM sqlite_schemaWHERE tbl_name IN ('device_tag_rowid','device_tag_wr')ORDER BY tbl_name, type, name;

In a modern SQLite build, table_list reports wr=0 for the ordinary table and wr=1 for the WITHOUT ROWID table. The exact index listing is metadata, not a performance benchmark; the key lesson is that the storage organization differs even though the logical primary key is the same.

WITHOUT ROWID has deliberate restrictions and API differences

Removing rowid means features that fundamentally depend on rowid cannot work the same way. These are not defects; they are consequences of choosing a different table organization.

BehaviorWITHOUT ROWID consequence
PRIMARY KEYRequired. A WITHOUT ROWID table without one is rejected.
NULL in primary-key columnsRejected; primary-key columns are effectively NOT NULL.
INTEGER PRIMARY KEY aliasNo special rowid alias exists.
AUTOINCREMENTNot allowed because the mechanism depends on rowid.
rowid/_rowid_/oidNo hidden rowid exists to expose.
last_insert_rowid()Inserts into WITHOUT ROWID tables do not update that connection value.
Incremental BLOB I/OThe rowid-based incremental BLOB API cannot target WITHOUT ROWID rows.
C update hookThe traditional update hook does not fire for WITHOUT ROWID changes because its callback includes a rowid.
Application boundary

Most beginner SQL code does not call incremental BLOB or C update-hook APIs. You still need to know the limitation before selecting WITHOUT ROWID for a library or framework that depends on those APIs behind the scenes.

Prove that the logical answers stay the same

Insert identical data into both tables. The point of WITHOUT ROWID is not to change relational meaning.

sql · same data, different storage identity
INSERT INTO device_tag_rowid(device_id, tag) VALUES  (1, 'pump'),  (1, 'critical'),  (2, 'fan');INSERT INTO device_tag_wr(device_id, tag)SELECT device_id, tag FROM device_tag_rowid;SELECT device_id, tagFROM device_tag_rowidORDER BY device_id, tag;SELECT device_id, tagFROM device_tag_wrORDER BY device_id, tag;-- This succeeds only on the ordinary rowid table:SELECT rowid, device_id, tagFROM device_tag_rowidORDER BY rowid;-- Expect an error: no such column: rowidSELECT rowid, device_id, tagFROM device_tag_wr;

Both first queries return the same three logical pairs. The final query fails because device_tag_wr has no hidden rowid. That failure is useful evidence that the table form is genuinely different.

Composite PRIMARY KEY nullability exposes an important SQLite difference

Historical compatibility means an ordinary non-STRICT rowid table can allow NULL in many declared PRIMARY KEY forms unless you also declare NOT NULL. WITHOUT ROWID follows the standard expectation and rejects NULL for every primary-key column.

sql · primary-key nullability contrast
DROP TABLE IF EXISTS ordinary_key_quirk;DROP TABLE IF EXISTS strict_key_behavior;CREATE TABLE ordinary_key_quirk (    a TEXT,    b TEXT,    PRIMARY KEY (a,b));CREATE TABLE strict_key_behavior (    a TEXT,    b TEXT,    PRIMARY KEY (a,b)) WITHOUT ROWID;-- May succeed in the ordinary legacy-compatible table:INSERT INTO ordinary_key_quirk(a,b) VALUES (NULL, 'x');-- Fails because WITHOUT ROWID PK columns are NOT NULL:INSERT INTO strict_key_behavior(a,b) VALUES (NULL, 'x');

Do not exploit the legacy quirk. In production schemas, declare required key columns NOT NULL explicitly when that communicates the model, and let later chapters add STRICT where appropriate.

EXPLAIN QUERY PLAN is evidence, not a benchmark

You can ask SQLite how it plans a lookup, but plan text and cost decisions are version- and data-dependent. Use it to learn which key or index is chosen, not to claim a measured speedup.

sql · inspect lookup strategy
EXPLAIN QUERY PLANSELECT tagFROM device_tag_rowidWHERE device_id=1 AND tag='critical';EXPLAIN QUERY PLANSELECT tagFROM device_tag_wrWHERE device_id=1 AND tag='critical';

Both queries should be able to use primary-key information for the exact pair. The descriptions may differ because one table has a separate uniqueness structure and the other is directly organized by the declared key. Chapter 10 teaches query-plan interpretation systematically; Chapter 11 examines file pages and b-trees.

Do not overclaim

A smaller schema diagram does not prove a smaller database file, and a plan that says SEARCH does not prove lower latency. Measure representative data, page counts, file size, and timed workloads before making production performance claims.

Lab: choose table form from the access pattern

Create chapter03_without_rowid.db. Keep both variants so you can inspect them throughout later chapters.

text · side-by-side table-form lab
sqlite3 chapter03_without_rowid.dbCREATE TABLE assignment_rowid (    device_code TEXT NOT NULL,    technician  TEXT NOT NULL,    role        TEXT NOT NULL,    PRIMARY KEY (device_code, technician));CREATE TABLE assignment_wr (    device_code TEXT NOT NULL,    technician  TEXT NOT NULL,    role        TEXT NOT NULL,    PRIMARY KEY (device_code, technician)) WITHOUT ROWID;INSERT INTO assignment_rowid VALUES ('PUMP-007','Leila','owner'), ('PUMP-007','Arman','reviewer'), ('FAN-014','Arman','owner');INSERT INTO assignment_wr SELECT * FROM assignment_rowid;PRAGMA table_list('assignment_rowid');PRAGMA table_list('assignment_wr');SELECT * FROM assignment_rowid ORDER BY device_code, technician;SELECT * FROM assignment_wr ORDER BY device_code, technician;EXPLAIN QUERY PLANSELECT role FROM assignment_wrWHERE device_code='PUMP-007' AND technician='Leila';

The logical query results must match. The design question is whether the composite key is stable, compact enough, and central enough to the workload that primary-key organization is attractive. Retain both versions for later measurement rather than declaring a winner now.

WITHOUT ROWID review

Choose based on mechanism, not fashion.

  1. Why must every WITHOUT ROWID table declare a primary key?
  2. Why does AUTOINCREMENT fail on WITHOUT ROWID?
  3. Why might a composite-key table use less duplicated key storage as WITHOUT ROWID?
  4. Why might a very wide text primary key reduce the benefit?
  5. What evidence should you collect before claiming it is faster?
Review the answers

The declared primary key replaces the missing rowid as the table key; AUTOINCREMENT is specifically a rowid allocation mechanism; ordinary rowid tables can store a composite/text key both in the row plus uniqueness structure whereas WITHOUT ROWID organizes by that key; wide keys can reduce b-tree fan-out; and performance claims require representative plans plus actual storage/timing measurements.

Summary and bridge to connection-scoped storage

WITHOUT ROWID is best understood as a storage organization choice for tables whose true key is not the ordinary integer rowid. It brings stricter primary-key nullability and removes rowid-dependent behaviors. With table identity understood, Lesson 4 changes dimensions: instead of asking how rows are keyed, it asks where the whole schema lives and how long it remains visible.

References

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.