Turso and LibSQL — SQLite for Production Applications
A practical guide to Turso and LibSQL — edge SQLite with embedded replicas, multi-region replication, and how to use it with Drizzle ORM for production applications.
SQLite has always been the best database that nobody uses in production. It's the most deployed database in the world (literally on every phone, every browser, every operating system), it's absurdly fast for reads, it needs zero configuration, and a single file contains your entire database. But it has one limitation that kept it out of server-side production: it doesn't handle concurrent writes from multiple servers.
Turso fixes that. Built on LibSQL (an open-source fork of SQLite), Turso adds multi-region replication, embedded replicas, and a serverless HTTP API while keeping the SQLite interface you already know. Your reads are local-speed (microseconds, not milliseconds), your writes replicate globally, and you don't need to manage a database server.
What LibSQL and Turso Actually Are
LibSQL is an open-source fork of SQLite maintained by the Turso team. It adds features SQLite won't accept upstream: server mode, replication,ALTER COLUMN, native RANDOM ROWID, and more. LibSQL is to SQLite what MariaDB was to MySQL — a compatible fork that moves faster.
Turso is the managed platform built on LibSQL. It hosts your databases, handles replication across regions, provides the HTTP/WebSocket edge API, and manages embedded replicas. Think of it as "managed LibSQL" the same way PlanetScale was "managed Vitess."
The architecture:
┌─────────────────────────────────────────────┐
│ Your App (any region) │
│ ┌────────────────────┐ │
│ │ Embedded Replica │ ← microsecond reads│
│ │ (local SQLite file) │ │
│ └────────┬───────────┘ │
│ │ sync │
│ ▼ │
│ ┌────────────────────┐ │
│ │ Turso Primary DB │ ← writes go here │
│ │ (closest region) │ │
│ └────────┬───────────┘ │
│ │ replicate │
│ ▼ │
│ ┌────────────────────┐ │
│ │ Read Replicas │ │
│ │ (global edge) │ │
│ └────────────────────┘ │
└─────────────────────────────────────────────┘
When to Use Turso (and When Not To)
Turso is a strong fit when:- You're building a read-heavy application (blogs, dashboards, e-commerce catalogs)
- You want sub-millisecond read latency without a caching layer
- You're deploying to the edge (Cloudflare Workers, Vercel Edge, Fly.io)
- You want per-tenant databases (multi-tenant SaaS with database-per-user)
- Your dataset is moderate (under 100GB per database)
- You want to avoid managing database infrastructure entirely
- You need complex joins across massive tables (100M+ rows)
- You need advanced features: full-text search with ranking, jsonb operators, PostGIS, materialized views
- Your write throughput is very high (thousands of writes per second sustained)
- You need strong multi-writer concurrency
- You already have a Postgres stack and it works fine
| Feature | Turso/LibSQL | PostgreSQL | MySQL |
|---|---|---|---|
| Read latency (edge) | ~0.5ms (embedded) | 20-80ms (network) | 20-80ms (network) |
| Write latency | 10-50ms (network to primary) | 5-20ms (local) | 5-20ms (local) |
| Max DB size | 100GB | Terabytes+ | Terabytes+ |
| Concurrent writes | Single writer | Multi-writer MVCC | Multi-writer MVCC |
| Setup complexity | Zero | Medium-High | Medium |
| Edge deployment | Native | Needs proxy | Needs proxy |
| Per-tenant DBs | First-class (10K+ DBs free) | Expensive | Expensive |
| SQL dialect | SQLite | PostgreSQL | MySQL |
Getting Started
Install the CLI and Create a Database
# Install Turso CLI
curl -sSfL https://get.tur.so/install.sh | bash
# Authenticate
turso auth login
# Create a database (picks closest region automatically)
turso db create my-app-db
# Or specify a region
turso db create my-app-db --location lhr # London
# Get connection URL
turso db show my-app-db --url
# → libsql://my-app-db-yourorg.turso.io
# Create an auth token
turso db tokens create my-app-db
# → eyJhbGciOi...
Connect from Node.js
npm install @libsql/client
import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.TURSO_DATABASE_URL!, // libsql://...
authToken: process.env.TURSO_AUTH_TOKEN!,
});
// Simple query
const result = await db.execute("SELECT * FROM users WHERE active = 1");
console.log(result.rows);
// → [{ id: 1, name: "Alice", email: "alice@example.com", active: 1 }]
// Parameterized query (always use this — never interpolate values)
const user = await db.execute({
sql: "SELECT * FROM users WHERE id = ?",
args: [42],
});
// Insert
await db.execute({
sql: "INSERT INTO users (name, email) VALUES (?, ?)",
args: ["Bob", "bob@example.com"],
});
// Transaction
await db.batch([
{
sql: "UPDATE accounts SET balance = balance - ? WHERE id = ?",
args: [100, 1],
},
{
sql: "UPDATE accounts SET balance = balance + ? WHERE id = ?",
args: [100, 2],
},
]);
Embedded Replicas — The Killer Feature
This is what makes Turso genuinely different. An embedded replica syncs a local SQLite copy to your application server. Reads hit the local file (microseconds). Writes go to the remote primary and sync back.
import { createClient } from "@libsql/client";
const db = createClient({
url: "file:./local-replica.db", // Local SQLite file
syncUrl: process.env.TURSO_DATABASE_URL!, // Remote primary
authToken: process.env.TURSO_AUTH_TOKEN!,
syncInterval: 60, // Sync every 60 seconds
});
// First sync — pulls the full database locally
await db.sync();
// This read hits the LOCAL file — ~0.1ms, not 30ms
const posts = await db.execute(
"SELECT * FROM posts ORDER BY created_at DESC LIMIT 20"
);
// Writes go to the remote primary, then sync back
await db.execute({
sql: "INSERT INTO posts (title, body) VALUES (?, ?)",
args: ["New Post", "Content here..."],
});
// Manual sync if you need fresh data immediately
await db.sync();
For a read-heavy app like a blog or dashboard, this is transformative. You get local SQLite speed without any caching layer, and you don't manage replication yourself.
Setting Up with Drizzle ORM
Drizzle has first-class Turso support, and the combination is clean.
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
Define Your Schema
// src/db/schema.ts
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
role: text("role", { enum: ["admin", "user", "editor"] })
.notNull()
.default("user"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
body: text("body").notNull(),
authorId: integer("author_id")
.notNull()
.references(() => users.id),
published: integer("published", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const tags = sqliteTable("tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
});
export const postTags = sqliteTable("post_tags", {
postId: integer("post_id")
.notNull()
.references(() => posts.id),
tagId: integer("tag_id")
.notNull()
.references(() => tags.id),
});
Configure Drizzle
// src/db/index.ts
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
export const db = drizzle(client, { schema });
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "turso",
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
});
Run Migrations
# Generate migration files from schema changes
npx drizzle-kit generate
# Push directly to database (dev only)
npx drizzle-kit push
# Apply migrations in production
npx drizzle-kit migrate
Query with Drizzle
import { db } from "./db";
import { users, posts, postTags, tags } from "./db/schema";
import { eq, desc, and, like, sql } from "drizzle-orm";
// Get all published posts with author info
const publishedPosts = await db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
authorName: users.name,
createdAt: posts.createdAt,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(20);
// Get post with tags
const postWithTags = await db
.select({
post: posts,
tagName: tags.name,
})
.from(posts)
.leftJoin(postTags, eq(posts.id, postTags.postId))
.leftJoin(tags, eq(postTags.tagId, tags.id))
.where(eq(posts.slug, "my-first-post"));
// Insert with returning
const [newUser] = await db
.insert(users)
.values({
name: "Charlie",
email: "charlie@example.com",
role: "editor",
})
.returning();
// Update
await db
.update(posts)
.set({ published: true })
.where(eq(posts.id, 5));
// Delete
await db.delete(posts).where(eq(posts.id, 10));
Multi-Region Replication
Turso replicates your database to multiple edge locations. Your app connects to the nearest replica for reads.
# Add replicas in specific regions
turso db replicate my-app-db --location sin # Singapore
turso db replicate my-app-db --location iad # Virginia
turso db replicate my-app-db --location cdg # Paris
# List replicas
turso db show my-app-db --instances
Writes always go to the primary. Reads go to the nearest replica. Replication lag is typically under 200ms globally.
For apps deployed on Fly.io or similar platforms where your app runs in multiple regions, this means every instance reads from a nearby replica without you writing any routing logic.
Per-Tenant Databases (Multi-Tenancy)
This is Turso's most underrated feature. You can create thousands of databases under one account. Each tenant gets an isolated SQLite database.
// Create a database per tenant on signup
import { TursoClient } from "@tursodatabase/api";
const turso = new TursoClient({
token: process.env.TURSO_API_TOKEN!,
org: "my-org",
});
async function createTenantDB(tenantId: string) {
const db = await turso.databases.create(tenant-${tenantId}, {
group: "default", // Shares infrastructure, separate data
schema: "schema-db", // Apply schema from a template database
});
const token = await turso.databases.createToken(tenant-${tenantId});
return {
url: libsql://tenant-${tenantId}-my-org.turso.io,
token: token.jwt,
};
}
Schema databases let you define the table structure once and apply it to every new tenant database. When you update the schema database, all tenants can be migrated.
This pattern works brilliantly for SaaS apps. Each customer's data is physically isolated, there's no row-level security to mess up, and you can even let customers export their entire database as a single SQLite file.
Pricing Reality Check
Turso's free tier is generous for small projects:
| Tier | Databases | Storage | Rows Read/Month | Rows Written/Month | Price |
|---|---|---|---|---|---|
| Starter | 500 | 9 GB | 24 billion | 36 million | Free |
| Scaler | 10,000 | 24 GB+ | Unlimited | Unlimited | $29/mo |
| Pro | Unlimited | Custom | Unlimited | Unlimited | Custom |
Compare to managed Postgres: Neon's free tier gives you 0.5GB storage. Supabase gives you 500MB. PlanetScale gives you 5GB. Turso's 9GB free tier with 500 databases is genuinely competitive.
Real Use Cases
Content platforms: We use Turso for several of the blogsites on codeup.dev where we need dynamic features like view counts and user interactions. Embedded replicas mean read queries for page views are essentially free in terms of latency. SaaS with tenant isolation: Each customer gets their own database. No shared tables, no complex RLS policies, and you can shard by database rather than by row. Edge-first applications: If you're deploying to Cloudflare Workers or Vercel Edge Functions, Turso's HTTP API works where TCP-based databases don't. You get a real relational database at the edge, not just KV storage. Mobile app backends: The SQLite-compatible interface means you can prototype with a local SQLite file and switch to Turso for production without changing queries.What Turso Doesn't Do Well
Be honest about the tradeoffs:
- No stored procedures or triggers (SQLite limitation, though LibSQL is adding some)
- No full-text search with ranking — SQLite's FTS5 works but isn't as capable as Postgres's
ts_vectoror Elasticsearch - Write throughput caps out around a few thousand writes per second on the primary. If you're ingesting time-series data at high volume, look at ClickHouse or TimescaleDB.
- No LISTEN/NOTIFY — if you need real-time database change notifications, you'll need to build that layer yourself
- 100GB max per database — fine for most apps, not enough for large analytics datasets
Local Development
For local dev, just use a local SQLite file. No Turso account needed.
// src/db/index.ts
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
const client = createClient({
url: process.env.NODE_ENV === "production"
? process.env.TURSO_DATABASE_URL!
: "file:./local.db",
authToken: process.env.NODE_ENV === "production"
? process.env.TURSO_AUTH_TOKEN!
: undefined,
});
export const db = drizzle(client, { schema });
# Apply schema to local file
npx drizzle-kit push
# Open local database in any SQLite GUI
# DB Browser for SQLite, TablePlus, DataGrip — all work
This is a genuine advantage over Postgres or MySQL, where local development either requires Docker, a local server install, or a cloud dev database. With Turso, file:./local.db just works.
Should You Use It?
If your application is read-heavy, moderate in size, and benefits from edge deployment — yes. Turso with embedded replicas gives you read performance that no network-bound database can match, with a developer experience that's simpler than managing Postgres.
If you're building the next Netflix analytics pipeline or a financial trading system with complex transactional requirements, stick with Postgres.
For everything in between — and that's most applications — Turso is worth evaluating seriously. The fact that you can start with a local SQLite file, graduate to Turso's managed platform, and never change a query is a genuinely good developer experience story.