Back to Blog

Using SQLite with Bun: A Complete Guide

JayJay

Bun ships with a native SQLite driver built directly into the runtime. No npm install, no native compilation, no configuration. Just import { Database } from "bun:sqlite" and you're working with SQLite. It's remarkably fast, often faster than better-sqlite3, and the API is clean.

Getting Started

TYPESCRIPT
import { Database } from "bun:sqlite";

// Open a database (creates if doesn't exist)
const db = new Database("myapp.db");

// Or use in-memory
const memDb = new Database(":memory:");

That's it. No dependencies, no setup.

Basic Operations

Creating Tables

TYPESCRIPT
db.run(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT UNIQUE NOT NULL,
    name TEXT,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )
`);

Inserting Data

TYPESCRIPT
// Single insert
db.run(
  "INSERT INTO users (email, name) VALUES (?, ?)",
  ["alice@example.com", "Alice"]
);

// Or use named parameters
db.run(
  "INSERT INTO users (email, name) VALUES ($email, $name)",
  { $email: "bob@example.com", $name: "Bob" }
);

Querying Data

TYPESCRIPT
// Get all rows
const users = db.query("SELECT * FROM users").all();
// [{ id: 1, email: "alice@example.com", name: "Alice", ... }, ...]

// Get one row
const user = db.query("SELECT * FROM users WHERE id = ?").get(1);
// { id: 1, email: "alice@example.com", name: "Alice", ... }

// Get with parameters
const user = db.query("SELECT * FROM users WHERE email = ?").get("alice@example.com");

Prepared Statements

For repeated queries, prepare once and reuse:

TYPESCRIPT
const findByEmail = db.prepare("SELECT * FROM users WHERE email = ?");

// Use many times
const alice = findByEmail.get("alice@example.com");
const bob = findByEmail.get("bob@example.com");

// With all()
const stmt = db.prepare("SELECT * FROM users WHERE created_at > ?");
const recentUsers = stmt.all("2024-01-01");

Prepared statements are significantly faster for repeated operations.

Transactions

Wrap multiple operations in a transaction:

TYPESCRIPT
const insertUser = db.prepare(
  "INSERT INTO users (email, name) VALUES (?, ?)"
);

const insertMany = db.transaction((users) => {
  for (const user of users) {
    insertUser.run(user.email, user.name);
  }
});

// All inserts succeed or all fail
insertMany([
  { email: "user1@example.com", name: "User 1" },
  { email: "user2@example.com", name: "User 2" },
  { email: "user3@example.com", name: "User 3" },
]);

Transactions also dramatically improve performance for bulk inserts, hundreds of times faster than individual inserts.

TypeScript Support

Bun's SQLite has excellent TypeScript support:

TYPESCRIPT
interface User {
  id: number;
  email: string;
  name: string | null;
  created_at: string;
}

// Type your queries
const getUser = db.query<User, [number]>("SELECT * FROM users WHERE id = ?");
const user = getUser.get(1);
// user is User | null, fully typed

// For all()
const getAllUsers = db.query<User, []>("SELECT * FROM users");
const users = getAllUsers.all();
// users is User[]

Working with JSON

SQLite has JSON functions, and Bun handles them well:

TYPESCRIPT
db.run(`
  CREATE TABLE IF NOT EXISTS products (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    metadata TEXT  -- Store JSON here
  )
`);

// Insert JSON
const metadata = { color: "blue", size: "large", tags: ["sale", "featured"] };
db.run(
  "INSERT INTO products (name, metadata) VALUES (?, ?)",
  ["Widget", JSON.stringify(metadata)]
);

// Query JSON fields (SQLite JSON functions)
const blueProducts = db.query(`
  SELECT * FROM products
  WHERE json_extract(metadata, '$.color') = ?
`).all("blue");

// Parse JSON in results
const products = db.query("SELECT * FROM products").all().map(p => ({
  ...p,
  metadata: JSON.parse(p.metadata)
}));

Performance Tips

Use WAL Mode

Write-Ahead Logging improves concurrent read/write performance:

TYPESCRIPT
db.run("PRAGMA journal_mode = WAL");

This should be one of the first things you do after opening the database.

Use Prepared Statements

The difference is significant:

TYPESCRIPT
// Slow: parsing SQL every time
for (const user of users) {
  db.run("INSERT INTO users (email, name) VALUES (?, ?)", [user.email, user.name]);
}

// Fast: parse once, execute many
const insert = db.prepare("INSERT INTO users (email, name) VALUES (?, ?)");
for (const user of users) {
  insert.run(user.email, user.name);
}

Batch with Transactions

Individual inserts commit separately. Transactions batch commits:

TYPESCRIPT
// Slow: 1000 separate commits
for (let i = 0; i < 1000; i++) {
  db.run("INSERT INTO data (value) VALUES (?)", [i]);
}

// Fast: 1 commit
const insert = db.prepare("INSERT INTO data (value) VALUES (?)");
const insertBatch = db.transaction((values: number[]) => {
  for (const value of values) {
    insert.run(value);
  }
});
insertBatch(Array.from({ length: 1000 }, (_, i) => i));

The batched version can be 100x faster.

Common Patterns

Repository Pattern

TYPESCRIPT
import { Database } from "bun:sqlite";

interface User {
  id: number;
  email: string;
  name: string | null;
}

class UserRepository {
  private db: Database;
  private stmts: {
    findById: ReturnType<Database["prepare"]>;
    findByEmail: ReturnType<Database["prepare"]>;
    create: ReturnType<Database["prepare"]>;
    update: ReturnType<Database["prepare"]>;
    delete: ReturnType<Database["prepare"]>;
  };

  constructor(db: Database) {
    this.db = db;
    this.stmts = {
      findById: db.prepare("SELECT * FROM users WHERE id = ?"),
      findByEmail: db.prepare("SELECT * FROM users WHERE email = ?"),
      create: db.prepare("INSERT INTO users (email, name) VALUES (?, ?) RETURNING *"),
      update: db.prepare("UPDATE users SET email = ?, name = ? WHERE id = ? RETURNING *"),
      delete: db.prepare("DELETE FROM users WHERE id = ?"),
    };
  }

  findById(id: number): User | null {
    return this.stmts.findById.get(id) as User | null;
  }

  findByEmail(email: string): User | null {
    return this.stmts.findByEmail.get(email) as User | null;
  }

  create(email: string, name: string | null): User {
    return this.stmts.create.get(email, name) as User;
  }

  update(id: number, email: string, name: string | null): User | null {
    return this.stmts.update.get(email, name, id) as User | null;
  }

  delete(id: number): void {
    this.stmts.delete.run(id);
  }
}

Migrations

TYPESCRIPT
const migrations = [
  `CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT UNIQUE NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )`,
  `ALTER TABLE users ADD COLUMN name TEXT`,
  `CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
];

function migrate(db: Database) {
  db.run(`
    CREATE TABLE IF NOT EXISTS migrations (
      id INTEGER PRIMARY KEY,
      applied_at TEXT DEFAULT CURRENT_TIMESTAMP
    )
  `);

  const applied = new Set(
    db.query<{ id: number }, []>("SELECT id FROM migrations").all().map(r => r.id)
  );

  const run = db.transaction(() => {
    for (let i = 0; i < migrations.length; i++) {
      if (!applied.has(i)) {
        db.run(migrations[i]);
        db.run("INSERT INTO migrations (id) VALUES (?)", [i]);
      }
    }
  });

  run();
}

When to Use Bun SQLite

Great for:

  • Single-server applications
  • CLI tools and scripts
  • Development and prototyping
  • Embedded databases in desktop apps
  • Testing (fast in-memory databases)
  • Read-heavy workloads

Consider alternatives when:

  • You need multi-server access (use PostgreSQL/MySQL)
  • You need high write concurrency (SQLite has a single writer)
  • Your data exceeds what fits comfortably on one disk
  • You need advanced features like full-text search at scale

Comparison to other SQLite Libraries

Bun's SQLite is comparable to better-sqlite3 but with some advantages:

  • No native compilation: Works immediately, no node-gyp issues
  • Bundled: No dependency to install
  • Fast: Performance is on par with or better than better-sqlite3
  • TypeScript-first: Generic type parameters built in

The API is synchronous (like better-sqlite3), which is actually ideal for SQLite since it's file-based and synchronous anyway.

Conclusion

Bun's SQLite integration is one of the best features of the runtime. Zero setup, excellent performance, clean API, full TypeScript support. For many applications, especially tools, prototypes, and single-server deployments. It's all you need.

The combination of Bun's speed and SQLite's simplicity is compelling. If you're building something that doesn't need the complexity of a client-server database, give it a try.

Keep Reading