$ sqlite-stats mystery.db
=== Database === path : mystery.db file size : 128.0 KB (32 pages × 4096 bytes) encoding : UTF-8 journal mode : delete user_version : 0 application_id: 0 foreign_keys: on === Tables (5) === name rows cols idx approx comments 500 4 1 10.8 KB posts 200 5 2 49.9 KB users 50 4 1 1.4 KB ...
$ sqlite-stats mystery.db
=== Database === path : mystery.db file size : 128.0 KB (32 pages × 4096 bytes) encoding : UTF-8 journal mode : delete user_version : 0 application_id: 0 foreign_keys: on === Tables (5) === name rows cols idx approx comments 500 4 1 10.8 KB posts 200 5 2 49.9 KB users 50 4 1 1.4 KB ...
$ sqlite-stats mystery.db
=== Database === path : mystery.db file size : 128.0 KB (32 pages × 4096 bytes) encoding : UTF-8 journal mode : delete user_version : 0 application_id: 0 foreign_keys: on === Tables (5) === name rows cols idx approx comments 500 4 1 10.8 KB posts 200 5 2 49.9 KB users 50 4 1 1.4 KB ...
src/
├── main.rs entry + exit-code plumbing
├── cli.rs clap definitions
├── inspector.rs PRAGMA walks, pure given a Connection
├── explain.rs EXPLAIN QUERY PLAN → tree
└── formatters.rs human / json / markdown
src/
├── main.rs entry + exit-code plumbing
├── cli.rs clap definitions
├── inspector.rs PRAGMA walks, pure given a Connection
├── explain.rs EXPLAIN QUERY PLAN → tree
└── formatters.rs human / json / markdown
src/
├── main.rs entry + exit-code plumbing
├── cli.rs clap definitions
├── inspector.rs PRAGMA walks, pure given a Connection
├── explain.rs EXPLAIN QUERY PLAN → tree
└── formatters.rs human / json / markdown
clap = { version = "4.5", features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde_json = "1"
clap = { version = "4.5", features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde_json = "1"
clap = { version = "4.5", features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde_json = "1"
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
fn pragma_string(conn: &Connection, name: &str) -> Result<String> { let sql = format!("PRAGMA {name}"); let mut stmt = conn.prepare(&sql)?; let mut rows = stmt.query([])?; if let Some(row) = rows.next()? { let v: rusqlite::types::Value = row.get(0)?; Ok(value_to_string(v)) } else { Ok(String::new()) }
} pub fn read_database_info(conn: &Connection) -> Result<DatabaseInfo> { let page_size = pragma_i64(conn, "page_size")?; let page_count = pragma_i64(conn, "page_count")?; let encoding = pragma_string(conn, "encoding")?; let journal_mode = pragma_string(conn, "journal_mode")?; let user_version = pragma_i64(conn, "user_version")?; let application_id = pragma_i64(conn, "application_id")?; let foreign_keys_on = pragma_i64(conn, "foreign_keys")? != 0; // PRAGMA database_list gives us the resolved on-disk path. let mut path = String::from(":memory:"); let mut stmt = conn.prepare("PRAGMA database_list")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let name: String = row.get(1)?; if name == "main" { if let Some(p) = row.get::<_, Option<String>>(2)? { if !p.is_empty() { path = p; } } break; } } Ok(DatabaseInfo { path, page_size, page_count, file_size_bytes: page_size * page_count, encoding, journal_mode, user_version, application_id, foreign_keys_on, })
}
fn pragma_string(conn: &Connection, name: &str) -> Result<String> { let sql = format!("PRAGMA {name}"); let mut stmt = conn.prepare(&sql)?; let mut rows = stmt.query([])?; if let Some(row) = rows.next()? { let v: rusqlite::types::Value = row.get(0)?; Ok(value_to_string(v)) } else { Ok(String::new()) }
} pub fn read_database_info(conn: &Connection) -> Result<DatabaseInfo> { let page_size = pragma_i64(conn, "page_size")?; let page_count = pragma_i64(conn, "page_count")?; let encoding = pragma_string(conn, "encoding")?; let journal_mode = pragma_string(conn, "journal_mode")?; let user_version = pragma_i64(conn, "user_version")?; let application_id = pragma_i64(conn, "application_id")?; let foreign_keys_on = pragma_i64(conn, "foreign_keys")? != 0; // PRAGMA database_list gives us the resolved on-disk path. let mut path = String::from(":memory:"); let mut stmt = conn.prepare("PRAGMA database_list")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let name: String = row.get(1)?; if name == "main" { if let Some(p) = row.get::<_, Option<String>>(2)? { if !p.is_empty() { path = p; } } break; } } Ok(DatabaseInfo { path, page_size, page_count, file_size_bytes: page_size * page_count, encoding, journal_mode, user_version, application_id, foreign_keys_on, })
}
fn pragma_string(conn: &Connection, name: &str) -> Result<String> { let sql = format!("PRAGMA {name}"); let mut stmt = conn.prepare(&sql)?; let mut rows = stmt.query([])?; if let Some(row) = rows.next()? { let v: rusqlite::types::Value = row.get(0)?; Ok(value_to_string(v)) } else { Ok(String::new()) }
} pub fn read_database_info(conn: &Connection) -> Result<DatabaseInfo> { let page_size = pragma_i64(conn, "page_size")?; let page_count = pragma_i64(conn, "page_count")?; let encoding = pragma_string(conn, "encoding")?; let journal_mode = pragma_string(conn, "journal_mode")?; let user_version = pragma_i64(conn, "user_version")?; let application_id = pragma_i64(conn, "application_id")?; let foreign_keys_on = pragma_i64(conn, "foreign_keys")? != 0; // PRAGMA database_list gives us the resolved on-disk path. let mut path = String::from(":memory:"); let mut stmt = conn.prepare("PRAGMA database_list")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let name: String = row.get(1)?; if name == "main" { if let Some(p) = row.get::<_, Option<String>>(2)? { if !p.is_empty() { path = p; } } break; } } Ok(DatabaseInfo { path, page_size, page_count, file_size_bytes: page_size * page_count, encoding, journal_mode, user_version, application_id, foreign_keys_on, })
}
let mut stmt = conn.prepare( "SELECT name, type FROM sqlite_master \ WHERE type IN ('table','view') \ ORDER BY type, name",
)?;
let mut stmt = conn.prepare( "SELECT name, type FROM sqlite_master \ WHERE type IN ('table','view') \ ORDER BY type, name",
)?;
let mut stmt = conn.prepare( "SELECT name, type FROM sqlite_master \ WHERE type IN ('table','view') \ ORDER BY type, name",
)?;
pub fn quote_ident(s: &str) -> String { let escaped = s.replace('"', "\"\""); format!("\"{escaped}\"")
}
pub fn quote_ident(s: &str) -> String { let escaped = s.replace('"', "\"\""); format!("\"{escaped}\"")
}
pub fn quote_ident(s: &str) -> String { let escaped = s.replace('"', "\"\""); format!("\"{escaped}\"")
}
SELECT name, SUM(payload) FROM dbstat GROUP BY name;
SELECT name, SUM(payload) FROM dbstat GROUP BY name;
SELECT name, SUM(payload) FROM dbstat GROUP BY name;
let dbstat_ok = conn .prepare("SELECT name, SUM(payload) FROM dbstat GROUP BY name") .is_ok(); if dbstat_ok { // fill a BTreeMap<String, i64>
}
let dbstat_ok = conn .prepare("SELECT name, SUM(payload) FROM dbstat GROUP BY name") .is_ok(); if dbstat_ok { // fill a BTreeMap<String, i64>
}
let dbstat_ok = conn .prepare("SELECT name, SUM(payload) FROM dbstat GROUP BY name") .is_ok(); if dbstat_ok { // fill a BTreeMap<String, i64>
}
#[derive(Debug, Clone)]
pub struct PlanNode { pub row: PlanRow, pub children: Vec<PlanNode>,
} pub fn build_tree(rows: &[PlanRow]) -> Vec<PlanNode> { fn children_of(rows: &[PlanRow], parent: i64) -> Vec<PlanNode> { rows.iter() .filter(|r| r.parent == parent) .map(|r| PlanNode { row: r.clone(), children: children_of(rows, r.id), }) .collect() } children_of(rows, 0)
} pub fn render_tree(nodes: &[PlanNode]) -> String { fn walk(out: &mut String, nodes: &[PlanNode], prefix: &str) { for (i, node) in nodes.iter().enumerate() { let last = i + 1 == nodes.len(); let glyph = if last { "└─ " } else { "├─ " }; out.push_str(prefix); out.push_str(glyph); out.push_str(&node.row.detail); out.push('\n'); let child_prefix = if last { format!("{prefix} ") } else { format!("{prefix}│ ") }; walk(out, &node.children, &child_prefix); } } let mut out = String::new(); walk(&mut out, nodes, ""); out
}
#[derive(Debug, Clone)]
pub struct PlanNode { pub row: PlanRow, pub children: Vec<PlanNode>,
} pub fn build_tree(rows: &[PlanRow]) -> Vec<PlanNode> { fn children_of(rows: &[PlanRow], parent: i64) -> Vec<PlanNode> { rows.iter() .filter(|r| r.parent == parent) .map(|r| PlanNode { row: r.clone(), children: children_of(rows, r.id), }) .collect() } children_of(rows, 0)
} pub fn render_tree(nodes: &[PlanNode]) -> String { fn walk(out: &mut String, nodes: &[PlanNode], prefix: &str) { for (i, node) in nodes.iter().enumerate() { let last = i + 1 == nodes.len(); let glyph = if last { "└─ " } else { "├─ " }; out.push_str(prefix); out.push_str(glyph); out.push_str(&node.row.detail); out.push('\n'); let child_prefix = if last { format!("{prefix} ") } else { format!("{prefix}│ ") }; walk(out, &node.children, &child_prefix); } } let mut out = String::new(); walk(&mut out, nodes, ""); out
}
#[derive(Debug, Clone)]
pub struct PlanNode { pub row: PlanRow, pub children: Vec<PlanNode>,
} pub fn build_tree(rows: &[PlanRow]) -> Vec<PlanNode> { fn children_of(rows: &[PlanRow], parent: i64) -> Vec<PlanNode> { rows.iter() .filter(|r| r.parent == parent) .map(|r| PlanNode { row: r.clone(), children: children_of(rows, r.id), }) .collect() } children_of(rows, 0)
} pub fn render_tree(nodes: &[PlanNode]) -> String { fn walk(out: &mut String, nodes: &[PlanNode], prefix: &str) { for (i, node) in nodes.iter().enumerate() { let last = i + 1 == nodes.len(); let glyph = if last { "└─ " } else { "├─ " }; out.push_str(prefix); out.push_str(glyph); out.push_str(&node.row.detail); out.push('\n'); let child_prefix = if last { format!("{prefix} ") } else { format!("{prefix}│ ") }; walk(out, &node.children, &child_prefix); } } let mut out = String::new(); walk(&mut out, nodes, ""); out
}
$ sqlite-stats demo.db --explain-query 'SELECT * FROM posts WHERE user_id = 1'
└─ SEARCH posts USING INDEX idx_posts_user (user_id=?)
$ sqlite-stats demo.db --explain-query 'SELECT * FROM posts WHERE user_id = 1'
└─ SEARCH posts USING INDEX idx_posts_user (user_id=?)
$ sqlite-stats demo.db --explain-query 'SELECT * FROM posts WHERE user_id = 1'
└─ SEARCH posts USING INDEX idx_posts_user (user_id=?)
sqlite-stats big.db --format json | jq '.tables | map(select(.row_count > 10000))'
sqlite-stats big.db --format json | jq '.tables | map(select(.row_count > 10000))'
sqlite-stats big.db --format json | jq '.tables | map(select(.row_count > 10000))'
fn make_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(r#" CREATE TABLE users( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE ); CREATE TABLE posts( id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id), title TEXT NOT NULL, body TEXT DEFAULT '' ); CREATE INDEX idx_posts_user ON posts(user_id); INSERT INTO users(...) VALUES (...); "#).unwrap(); conn
}
fn make_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(r#" CREATE TABLE users( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE ); CREATE TABLE posts( id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id), title TEXT NOT NULL, body TEXT DEFAULT '' ); CREATE INDEX idx_posts_user ON posts(user_id); INSERT INTO users(...) VALUES (...); "#).unwrap(); conn
}
fn make_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(r#" CREATE TABLE users( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE ); CREATE TABLE posts( id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id), title TEXT NOT NULL, body TEXT DEFAULT '' ); CREATE INDEX idx_posts_user ON posts(user_id); INSERT INTO users(...) VALUES (...); "#).unwrap(); conn
}
-weight: 500;">git clone https://github.com/sen-ltd/sqlite-stats
cd sqlite-stats
-weight: 500;">docker build -t sqlite-stats .
-weight: 500;">docker run --rm -v "$PWD":/work sqlite-stats /work/your.db
-weight: 500;">git clone https://github.com/sen-ltd/sqlite-stats
cd sqlite-stats
-weight: 500;">docker build -t sqlite-stats .
-weight: 500;">docker run --rm -v "$PWD":/work sqlite-stats /work/your.db
-weight: 500;">git clone https://github.com/sen-ltd/sqlite-stats
cd sqlite-stats
-weight: 500;">docker build -t sqlite-stats .
-weight: 500;">docker run --rm -v "$PWD":/work sqlite-stats /work/your.db
cargo run --release -- your.db
cargo run --release -- your.db
cargo run --release -- your.db - Open sqlite3 mystery.db.
- .tables — okay, there's stuff.
- .schema users — okay, there's a users table.
- "How big is this database actually?" → dig through docs.
- "Which table has the most rows?" → write a query per table.
- "Is there an index on user_id?" → .schema posts and squint.
- "Why is this query slow?" → EXPLAIN QUERY PLAN SELECT .... - PRAGMA page_size × PRAGMA page_count = actual file size, without stat()ing the file. Handy inside networked or read-only filesystems where you don't have std::fs::metadata.
- PRAGMA database_list returns a row per attached database (there's always at least main); column 2 is the on-disk path, which is an easy way to get the real file location from just a Connection.
- PRAGMA user_version and PRAGMA application_id are two 32-bit slots SQLite reserves for apps to stamp their own schema version / file-magic. Lots of apps use them; most dumps I inspect leave them at 0.
- PRAGMA foreign_keys has an annoying property: it's per-connection and defaults to off. The fact that the report says foreign_keys: on reflects whether this inspector connection has enforcement enabled, not whether the database was written with enforcement. That's a footgun I hit while writing the tests. - read_database_info returns a positive page size and UTF-8 encoding.
- inspect sees both tables and skips sqlite_%.
- Row counts are exact.
- PRAGMA table_info flags NOT NULL and PRIMARY KEY correctly.
- PRAGMA index_list finds both the named index and the auto-created unique index.
- Foreign keys are resolved.
- Each formatter emits its headline section.
- The EXPLAIN tree preserves parent/child relationships and renders children with a continuation character (│) in the prefix.
- quote_ident escapes embedded quotes. - Interactive mode. If you want to run queries, sqlite3 is already in $PATH. This tool stays non-interactive so it composes with pipes.
- Writes. The CLI opens the database SQLITE_OPEN_READ_ONLY. It won't bump user_version, won't VACUUM, won't repair anything. Safer to reach for, easier to reason about.
- Locking-sensitive PRAGMAs. A few PRAGMAs (notably wal_checkpoint) take write locks. The inspector avoids anything that modifies state.
- Per-page analysis. dbstat can give you a lot more than payload sums — unused bytes, overflow page count, etc. I deliberately kept the report surface small.