We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide24 min read

Turso, a distributed SQLite database close to the user

Turso is a SQLite compatible database with replicas embedded in your app. The Rust rewrite, local replicas, pricing, and a Neon comparison.

Turso, a distributed SQLite database close to the user

Turso is a SQLite compatible database offered as a service and designed around one idea: a copy of the data should sit as close as possible to the code reading it. In the extreme case that means a replica inside your application process, so a read never touches the network at all. The project is open source under MIT.

Embedded replicas, what this is really about

Most cloud databases address latency by placing replicas in several regions. The query still crosses the network, only over a shorter distance. Turso goes further and lets you hold the entire database as a file inside your application, synchronised with the primary instance.

The consequence is qualitative rather than quantitative. A read stops being a network call and becomes a local disk read, measured in fractions of a millisecond instead of tens. A whole class of problems disappears too: connection limits, brief network interruptions, latency growing with distance from a data centre.

The price is that writes still go to the primary instance, and the replica learns about them after a delay. You get a model where reads are instant but may show a state from a moment ago. For a product catalogue, content, or configuration that arrangement is ideal. For a seat counter or stock levels on the last item it is not, because two users will see the same availability.

The second price is size. Since the replica lives inside the application, it has to fit there. At a few hundred megabytes that is irrelevant; at tens of gigabytes it stops making sense, particularly in an on demand runtime where every cold start means downloading the file.

The Rust rewrite of the engine

This is the most important change of the last two years and also the reason older descriptions of this project mislead.

Turso originally rested on libSQL, a fork of the original SQLite written in C with replication and HTTP access added. That project still exists, remains open source, and is maintained, with repository changes as recent as July 2026.

In parallel the team concluded that some goals, above all asynchronous input and output plus memory safety, would be easier to reach by writing the engine from scratch than by reworking two decade old code. That produced Project Limbo, a complete rewrite of SQLite in Rust preserving the SQL dialect and file format. It was later renamed simply Turso and is now the primary direction of development.

Two concrete gains from that rewrite matter in practice. Multi version concurrency control lets several writers make progress at once, which the original SQLite could not do, since a write locked the whole database. Asynchronous input and output through a Linux kernel mechanism keeps a thread from idling while waiting on disk, which matters especially in runtimes billed by execution time.

In July 2026 the project went further and added experimental support for the Postgres protocol. The repository description speaks openly of an architecture meant to support several different frontends over one engine.

Turso against other cloud databases

FeatureTursoNeonPlanetScaleSupabase
EngineSQLite compatiblePostgreSQLMySQLPostgreSQL
Replica inside the appyesnonono
Local readfractions of a millisecondover the networkover the networkover the network
Database branchingyesyes, a strengthyeslimited
Open source engineyes, MITpartlynoyes
Complex querieslimitedfull PostgreSQLfull MySQLfull PostgreSQL

The choice comes down to the shape of the workload. If the application mostly reads, the data fits in a reasonably sized file, and latency matters to you, Turso wins clearly. If you write heavily, need complex queries with many joins, or want features only a mature PostgreSQL provides, pick PostgreSQL in any form and spare yourself the complication.

Key advantages of Turso

  1. Edge locations - Replicas around the world, close to users
  2. Embedded replicas - Local database copy inside your app
  3. libSQL - Open fork of SQLite with additional features
  4. Ultra-low latencies - Microseconds for local reads
  5. SQLite compatibility - Full compatibility with SQLite
  6. Serverless - Automatic scaling, zero management
  7. Cost-effective - Significantly cheaper than PostgreSQL/MySQL
  8. Simple - One file = entire database

Turso vs other databases

FeatureTursoPlanetScaleNeonD1 (Cloudflare)
EngineSQLite/libSQLVitess and PostgresPostgreSQLSQLite
Embedded replicasYesNoNoNo
BranchingYesYesYesNo
Engine open sourcelibSQL, yesNoNoNo

Free tier sizes and latency figures are deliberately absent from that table. They change several times a year, and at least one of the widely repeated numbers is simply wrong: PlanetScale withdrew its free tier, so its entry plan starts at a monthly fee rather than at zero. Check current limits with each vendor before making a decision that rests on them.

Turso vs SQLite

FeatureTursoSQLite
ReplicationAutomaticNone
Edge deploymentYesNo
HTTP accessYesNo
WebsocketsYesNo
ScalingAutomaticManual
BackupsAutomaticManual
Multi-regionYesNo

When to choose Turso?

  • Edge applications - Data close to users
  • Read-heavy workloads - Most operations are reads
  • Low latency critical - Microseconds matter
  • Simple schema - Relational data without complex JOINs
  • Cost-conscious - Cheaper alternative to PostgreSQL

Installation and configuration

Turso CLI

Code
Bash
# macOS / Linux
curl -sSfL https://get.tur.so/install.sh | bash

# macOS (Homebrew)
brew install tursodatabase/tap/turso

# Verify
turso --version

# Windows (WSL required)
curl -sSfL https://get.tur.so/install.sh | bash

Login and creating a database

Code
Bash
# Log in (opens browser)
turso auth login

# Or with a token
turso auth login --headless

# Create a new database
turso db create my-database

# With a specific location
turso db create my-database --location waw

# List databases
turso db list

# Database details
turso db show my-database

Available locations (30+)

Code
Bash
# List all locations
turso db locations

# Popular locations:
# waw - Warsaw, Poland
# fra - Frankfurt, Germany
# lhr - London, UK
# ams - Amsterdam, Netherlands
# cdg - Paris, France
# iad - N. Virginia, USA
# sfo - San Francisco, USA
# sin - Singapore
# nrt - Tokyo, Japan
# syd - Sydney, Australia
# gru - São Paulo, Brazil

Creating a token

Code
Bash
# Token with full permissions
turso db tokens create my-database

# Read-only token
turso db tokens create my-database --read-only

# Token with expiration
turso db tokens create my-database --expiration 7d

# Revoke token
turso db tokens revoke my-database <token-name>

Basic usage

Turso CLI Shell

Code
Bash
# Connect to the database (interactive shell)
turso db shell my-database

# Execute SQL
turso> CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  name TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

turso> INSERT INTO users (email, name) VALUES ('user@example.com', 'John');

turso> SELECT * FROM users;

# Exit
turso> .quit

TypeScript/JavaScript client

Code
Bash
npm install @libsql/client
TSdb/client.ts
TypeScript
// db/client.ts
import { createClient } from '@libsql/client'

export const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

// Or for local development
// export const db = createClient({
//   url: 'file:local.db'
// })
Code
TypeScript
// Basic operations
import { db } from './db/client'

// Query
const result = await db.execute('SELECT * FROM users')
console.log(result.rows)

// With parameters (named)
const user = await db.execute({
  sql: 'SELECT * FROM users WHERE id = :id',
  args: { id: 1 }
})

// With parameters (positional)
const users = await db.execute({
  sql: 'SELECT * FROM users WHERE email = ?',
  args: ['user@example.com']
})

// Insert
await db.execute({
  sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
  args: ['new@example.com', 'Jane']
})

// Update
await db.execute({
  sql: 'UPDATE users SET name = ? WHERE id = ?',
  args: ['Updated Name', 1]
})

// Delete
await db.execute({
  sql: 'DELETE FROM users WHERE id = ?',
  args: [1]
})

Transactions

Code
TypeScript
// Single transaction
const result = await db.transaction(async (tx) => {
  await tx.execute({
    sql: 'INSERT INTO accounts (user_id, balance) VALUES (?, ?)',
    args: [userId, 1000]
  })

  await tx.execute({
    sql: 'INSERT INTO transactions (account_id, amount) VALUES (?, ?)',
    args: [accountId, -100]
  })

  await tx.execute({
    sql: 'UPDATE accounts SET balance = balance - ? WHERE id = ?',
    args: [100, accountId]
  })

  return { success: true }
})

Batch queries

Code
TypeScript
// Execute multiple queries in a single roundtrip
const results = await db.batch([
  {
    sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
    args: ['user1@example.com', 'User 1']
  },
  {
    sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
    args: ['user2@example.com', 'User 2']
  },
  {
    sql: 'SELECT * FROM users'
  }
])

// results[0] - first INSERT
// results[1] - second INSERT
// results[2] - SELECT

Edge replicas

Adding replicas

Code
Bash
# Add a replica in a specific location
turso db replicate my-database waw
turso db replicate my-database fra
turso db replicate my-database sin

# List replicas
turso db show my-database

# Remove a replica
turso db replicate my-database waw --remove

Automatic routing

Code
TypeScript
// The client automatically connects to the nearest replica
const db = createClient({
  url: 'libsql://my-database-username.turso.io',
  authToken: process.env.TURSO_AUTH_TOKEN
})

// Reads -> nearest replica
// Writes -> primary (propagated to replicas)

Manual routing

Code
TypeScript
// Connect to a specific location
const db = createClient({
  url: 'libsql://my-database-username.turso.io?location=waw',
  authToken: process.env.TURSO_AUTH_TOKEN
})

Embedded replicas

Embedded replicas are a local copy of the database held inside the application itself.

Configuration

Code
TypeScript
import { createClient } from '@libsql/client'

const db = createClient({
  // Local replica
  url: 'file:local-replica.db',

  // Sync with remote
  syncUrl: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,

  // Optional: automatic sync
  syncInterval: 60  // seconds
})

Manual sync

Code
TypeScript
// Sync manually
await db.sync()

// After INSERT/UPDATE on remote
// Data will appear locally after sync

// Typical flow:
// 1. User reads from embedded replica (~0.5ms)
// 2. User writes to remote
// 3. App calls sync()
// 4. New data available locally

Use cases for embedded replicas

Code
TypeScript
// 1. Read-heavy applications
// Most reads from the local replica
const users = await db.execute('SELECT * FROM users LIMIT 100')
// ~0.5ms instead of ~50ms!

// 2. Offline-first apps
// Data available without internet
const cachedData = await db.execute('SELECT * FROM cache')

// 3. Edge functions
// Local replica in a Vercel Edge Function
export const config = { runtime: 'edge' }

export default async function handler() {
  const data = await db.execute('SELECT * FROM products')
  return Response.json(data.rows)
}

Embedded replicas in Next.js

TSlib/db.ts
TypeScript
// lib/db.ts
import { createClient } from '@libsql/client'

// Singleton pattern for the embedded replica
let db: ReturnType<typeof createClient> | null = null

export function getDb() {
  if (!db) {
    db = createClient({
      url: 'file:./local.db',
      syncUrl: process.env.TURSO_DATABASE_URL,
      authToken: process.env.TURSO_AUTH_TOKEN
    })
  }
  return db
}

// Sync on application startup
export async function initDb() {
  const db = getDb()
  await db.sync()
}
TSapp/api/users/route.ts
TypeScript
// app/api/users/route.ts
import { getDb } from '@/lib/db'
import { NextResponse } from 'next/server'

export async function GET() {
  const db = getDb()

  // Super fast read from the local replica
  const result = await db.execute('SELECT * FROM users')

  return NextResponse.json(result.rows)
}

export async function POST(request: Request) {
  const db = getDb()
  const data = await request.json()

  // Write goes to remote
  await db.execute({
    sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
    args: [data.email, data.name]
  })

  // Sync for immediate local availability
  await db.sync()

  return NextResponse.json({ success: true })
}

Drizzle ORM

Setup

Code
Bash
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
TSdb/schema.ts
TypeScript
// db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: text('created_at').default('CURRENT_TIMESTAMP')
})

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  content: text('content'),
  authorId: integer('author_id').notNull().references(() => users.id),
  published: integer('published', { mode: 'boolean' }).default(false),
  createdAt: text('created_at').default('CURRENT_TIMESTAMP')
})
TSdb/index.ts
TypeScript
// 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 })
TSdrizzle.config.ts
TypeScript
// drizzle.config.ts
import type { Config } from 'drizzle-kit'

export default {
  schema: './db/schema.ts',
  out: './drizzle',
  driver: 'turso',
  dbCredentials: {
    url: process.env.TURSO_DATABASE_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN!
  }
} satisfies Config

Migrations

Code
Bash
# Generate migrations
npx drizzle-kit generate:sqlite

# Apply migrations
npx drizzle-kit push:sqlite

# Studio (GUI)
npx drizzle-kit studio

Queries with Drizzle

Code
TypeScript
import { db } from '@/db'
import { users, posts } from '@/db/schema'
import { eq, and, desc, like } from 'drizzle-orm'

// Select all
const allUsers = await db.select().from(users)

// Select with where
const user = await db
  .select()
  .from(users)
  .where(eq(users.id, 1))

// Select specific columns
const emails = await db
  .select({ email: users.email })
  .from(users)

// Join
const postsWithAuthors = await db
  .select({
    post: posts,
    author: users
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(eq(posts.published, true))
  .orderBy(desc(posts.createdAt))

// Insert
const [newUser] = await db
  .insert(users)
  .values({
    email: 'new@example.com',
    name: 'New User'
  })
  .returning()

// Update
await db
  .update(users)
  .set({ name: 'Updated' })
  .where(eq(users.id, 1))

// Delete
await db
  .delete(users)
  .where(eq(users.id, 1))

// Search
const searchResults = await db
  .select()
  .from(users)
  .where(like(users.name, '%john%'))

Prisma

Setup

Code
Bash
npm install prisma @prisma/client
npm install @prisma/adapter-libsql @libsql/client
npx prisma init
prisma/schema.prisma
Prisma
// prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
}
TSlib/prisma.ts
TypeScript
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
import { PrismaLibSQL } from '@prisma/adapter-libsql'
import { createClient } from '@libsql/client'

const libsql = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

const adapter = new PrismaLibSQL(libsql)

export const prisma = new PrismaClient({ adapter })
Code
Bash
# Push schema to Turso
npx prisma db push

# Generate client
npx prisma generate
Code
TypeScript
// Usage
import { prisma } from '@/lib/prisma'

// Create
const user = await prisma.user.create({
  data: {
    email: 'user@example.com',
    name: 'John'
  }
})

// Read with relations
const posts = await prisma.post.findMany({
  where: { published: true },
  include: { author: true }
})

// Update
await prisma.user.update({
  where: { id: 1 },
  data: { name: 'Updated' }
})

// Delete
await prisma.post.delete({
  where: { id: 1 }
})

Database groups

Groups let you manage multiple databases together:

Code
Bash
# Create a group
turso group create my-group --location waw

# Add a database to the group
turso db create my-db --group my-group

# All databases in the group inherit locations
turso group locations add my-group fra

# List groups
turso group list

# Group-level tokens
turso group tokens create my-group

Multi-tenant architecture

Code
TypeScript
// Each tenant has its own database in the group
async function createTenantDb(tenantId: string) {
  // Use the Turso Platform API
  const response = await fetch(
    `https://api.turso.tech/v1/organizations/${orgId}/databases`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${TURSO_API_TOKEN}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name: `tenant-${tenantId}`,
        group: 'tenants-group'
      })
    }
  )

  return response.json()
}

// Route to the correct database
function getTenantDb(tenantId: string) {
  return createClient({
    url: `libsql://tenant-${tenantId}-${org}.turso.io`,
    authToken: process.env.TURSO_AUTH_TOKEN
  })
}

Schema management

Turso CLI schema

Code
Bash
# Show schema
turso db shell my-database ".schema"

# Export schema
turso db shell my-database ".schema" > schema.sql

# Import schema
turso db shell my-database < schema.sql

Migrations with Drizzle

Code
Bash
# Structure
migrations/
├── 0000_init.sql
├── 0001_add_posts.sql
└── 0002_add_comments.sql
TSmigrate.ts
TypeScript
// migrate.ts
import { migrate } from 'drizzle-orm/libsql/migrator'
import { db } from './db'

async function main() {
  console.log('Running migrations...')
  await migrate(db, { migrationsFolder: './drizzle' })
  console.log('Migrations complete!')
}

main()

Schema dump and restore

Code
Bash
# Dump the entire database
turso db shell my-database ".dump" > backup.sql

# Restore
turso db create my-database-restored
turso db shell my-database-restored < backup.sql

Integrations

Next.js App Router

TSapp/api/users/route.ts
TypeScript
// app/api/users/route.ts
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { NextResponse } from 'next/server'

export async function GET() {
  const allUsers = await db.select().from(users)
  return NextResponse.json(allUsers)
}

export async function POST(request: Request) {
  const data = await request.json()

  const [user] = await db
    .insert(users)
    .values(data)
    .returning()

  return NextResponse.json(user, { status: 201 })
}

Vercel Edge Functions

TSapp/api/edge/route.ts
TypeScript
// app/api/edge/route.ts
import { createClient } from '@libsql/client/web'

export const runtime = 'edge'

const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const id = searchParams.get('id')

  const result = await db.execute({
    sql: 'SELECT * FROM users WHERE id = ?',
    args: [id]
  })

  return Response.json(result.rows[0])
}

SvelteKit

TSsrc/lib/db.ts
TypeScript
// src/lib/db.ts
import { createClient } from '@libsql/client'
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from '$env/static/private'

export const db = createClient({
  url: TURSO_DATABASE_URL,
  authToken: TURSO_AUTH_TOKEN
})
TSsrc/routes/api/users/+server.ts
TypeScript
// src/routes/api/users/+server.ts
import { db } from '$lib/db'
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async () => {
  const result = await db.execute('SELECT * FROM users')
  return json(result.rows)
}

Remix

TSapp/utils/db.server.ts
TypeScript
// app/utils/db.server.ts
import { createClient } from '@libsql/client'

export const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})
TSapp/routes/users.tsx
TypeScript
// app/routes/users.tsx
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { db } from '~/utils/db.server'

export async function loader() {
  const result = await db.execute('SELECT * FROM users')
  return json({ users: result.rows })
}

export default function Users() {
  const { users } = useLoaderData<typeof loader>()

  return (
    <ul>
      {users.map((user: any) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

GitHub Actions CI/CD

.github/workflows/migrate.yml
YAML
# .github/workflows/migrate.yml
name: Database Migration

on:
  push:
    branches: [main]
    paths: ['drizzle/**']

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Run migrations
        run: npx drizzle-kit push:sqlite
        env:
          TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
          TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}

Performance tips

Indexes

Code
SQL
-- Create indexes for frequently queried columns
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_created ON posts(created_at DESC);

-- Composite index
CREATE INDEX idx_posts_author_published
ON posts(author_id, published);

Query optimization

Code
TypeScript
// Bad - fetches everything
const users = await db.execute('SELECT * FROM users')

// Good - only the columns you need
const users = await db.execute('SELECT id, name FROM users')

// Bad - no limit
const posts = await db.execute('SELECT * FROM posts ORDER BY created_at DESC')

// Good - with a limit
const posts = await db.execute({
  sql: 'SELECT * FROM posts ORDER BY created_at DESC LIMIT ?',
  args: [20]
})

// Dobrze - stronicowanie
const posts = await db.execute({
  sql: 'SELECT * FROM posts ORDER BY id LIMIT ? OFFSET ?',
  args: [20, page * 20]
})

Connection reuse

Code
TypeScript
// Bad - new client on every request
export async function handler() {
  const db = createClient({ ... })
  // ...
}

// Dobrze - jedna instancja klienta
let db: ReturnType<typeof createClient> | null = null

export function getDb() {
  if (!db) {
    db = createClient({
      url: process.env.TURSO_DATABASE_URL!,
      authToken: process.env.TURSO_AUTH_TOKEN!
    })
  }
  return db
}

Batch vs multiple queries

Code
TypeScript
// Bad - multiple roundtrips
await db.execute({ sql: 'INSERT INTO users ...', args: [...] })
await db.execute({ sql: 'INSERT INTO users ...', args: [...] })
await db.execute({ sql: 'INSERT INTO users ...', args: [...] })

// Good - a single roundtrip
await db.batch([
  { sql: 'INSERT INTO users ...', args: [...] },
  { sql: 'INSERT INTO users ...', args: [...] },
  { sql: 'INSERT INTO users ...', args: [...] }
])

Pricing

PlanMonthly priceDatabasesStorageRow readsRow writes
Free0 USD1005 GB500M10M
Developer4.99 USDunlimited9 GB2.5B25M
Scaler24.92 USDunlimited24 GB100B100M
Pro416.58 USDunlimited50 GB250B250M

The billing model differs from most databases, and understanding it decides whether the invoice surprises you. You do not pay for server uptime but for rows read and written. Exceeding a limit does not lock the database, it adds a charge: roughly one dollar per additional billion reads and per additional million writes, with rates falling on higher plans.

A third metric is easy to forget beside the other two: storage. Above the amount included in a plan you pay from seventy five cents per gigabyte on the developer plan, through fifty cents, down to forty five on the highest. On a database growing linearly that line is the only one that does not shrink after query optimisation, so it deserves tracking separately.

The asymmetry between reads and writes is worth noticing, because it says everything about this database's purpose. The free plan covers five hundred million reads and only ten million writes, fifty times fewer. That is not an oversight but a reflection of the architecture: the database is built for read dominated workloads.

Embedded replica synchronisation is billed separately, by gigabytes transferred. With a replica refreshed often across many application instances that line can grow faster than the reads themselves, so when planning, also count how often and in how many places you will synchronise.

Practical advice: count queries before choosing a plan. One page view running five queries returning twenty rows each is a hundred rows read. At a million views a month that is a hundred million reads, still inside the free plan but already in the same order of magnitude.

Best practices

Development workflow

Code
Bash
# 1. Local database for development
turso db create my-app-dev

# 2. Staging
turso db create my-app-staging

# 3. Production
turso db create my-app-prod --location waw
turso db replicate my-app-prod fra
turso db replicate my-app-prod iad

Environment variables

.env.local
ENV
# .env.local (development)
TURSO_DATABASE_URL=libsql://my-app-dev-username.turso.io
TURSO_AUTH_TOKEN=eyJhbGci...

# .env.production
TURSO_DATABASE_URL=libsql://my-app-prod-username.turso.io
TURSO_AUTH_TOKEN=eyJhbGci...

Error handling

Code
TypeScript
import { db } from '@/lib/db'

async function getUser(id: number) {
  try {
    const result = await db.execute({
      sql: 'SELECT * FROM users WHERE id = ?',
      args: [id]
    })

    if (result.rows.length === 0) {
      return null
    }

    return result.rows[0]
  } catch (error) {
    if (error instanceof Error) {
      console.error('Database error:', error.message)

      // Retry logic for network errors
      if (error.message.includes('network')) {
        // Retry...
      }
    }
    throw error
  }
}

Type safety

TStypes/db.ts
TypeScript
// types/db.ts
interface User {
  id: number
  email: string
  name: string | null
  createdAt: string
}

// Typed query
async function getUsers(): Promise<User[]> {
  const result = await db.execute('SELECT * FROM users')
  return result.rows as User[]
}

// Or with Drizzle (full type safety)
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'

const allUsers = await db.select().from(users)
// allUsers is automatically typed!

FAQ - frequently asked questions

When to use embedded replicas?

When you have a read-heavy workload and ultra-low latencies matter to you. Ideal for e-commerce (products), CMS (articles), and analytics dashboards.

Does Turso support foreign keys?

Yes! Unlike PlanetScale, Turso/libSQL fully supports SQLite foreign keys.

How does embedded replica synchronization work?

The embedded replica syncs with the primary when you call db.sync(). You can also set syncInterval for automatic synchronization.

Can I use Turso with an existing SQLite database?

Yes! You can import an existing SQLite database into Turso via the CLI or API.

What is the difference between Turso and D1 (Cloudflare)?

D1 only works on Cloudflare Workers. Turso is platform-independent and offers embedded replicas along with more edge locations.

Does Turso support full-text search?

Yes, through the SQLite FTS5 extension. libSQL supports all standard SQLite extensions.

Code
SQL
-- FTS5 example
CREATE VIRTUAL TABLE posts_fts USING fts5(title, content);
INSERT INTO posts_fts SELECT title, content FROM posts;
SELECT * FROM posts_fts WHERE posts_fts MATCH 'search query';

Does a read from a replica return current data?

Not always, and that is this model's fundamental limitation. A replica synchronises with the primary, so a moment passes between a write and its local visibility. Where you need certainty, direct the read to the primary instance.

How does Turso differ from libSQL?

libSQL is a fork of the original SQLite in C that the service previously rested on and that is still maintained. Turso is the name of both the company and service and of the new engine written from scratch in Rust, which is today the primary direction of development.

When an embedded replica makes sense and when it does not

This decision returns in every deployment and deserves taking deliberately, since the whole architecture hangs on it rather than just the connection configuration.

An embedded replica makes sense when three conditions hold at once. The database is small enough that fetching it does not hurt, the application lives long enough that fetching happens rarely, and reads make up the decisive majority of traffic. The classic example is a content site where articles change a few times a day and are read thousands of times.

It stops making sense when any of those conditions fails. In an environment spinning up a new instance per request, the replica never gets a chance to pay off, since the fetch cost falls on a single call. With a database growing into gigabytes, fetching stops being a background operation and becomes a problem. Under a write dominated load the replica only adds synchronisation latency and gives nothing back.

There is also a middle option, often forgotten and frequently the most sensible. You can use the database over an ordinary network connection, with no local replica, and still gain the simplicity of the model and billing by rows rather than by server time. The replica is an optional feature rather than a condition of using the service, and adding it later requires no change to schema or queries.

The practical rollout order therefore runs like this: start with a network connection, measure latency on real traffic, and only then decide whether a local replica changes anything. The reverse order, starting with a replica because it sounds interesting, usually ends in wrestling with cold starts over a problem that was not there.

Common mistakes

The first is assuming immediate consistency. Code that writes a record and immediately reads it back from a replica will work correctly nine times out of ten and fail on the tenth, usually in production. After a write, either read from the primary or work with the value you just wrote.

The second is underestimating cold start cost with embedded replicas. A new application instance has to download the database file before answering its first request. At a hundred megabytes, in a runtime that spins instances up on demand, that cost can exceed the latency saving.

The third is counting queries rather than rows read. A query without an index that scans a whole table consumes as many reads as the table has rows, even if it returns one. A missing index translates directly into the bill here, not only into response time.

The fourth is treating this as a full PostgreSQL. The SQLite dialect is narrower, typing behaves differently, and some window functions and data types simply do not exist. Check that your queries fit within that dialect before planning a migration.

The fifth is storing data that grows without bound. Event logs, change history, or telemetry quickly exceed the size at which an embedded replica makes sense. Keep those elsewhere and leave in this database what the application genuinely reads on every request.

The engine source and releases live in the Turso repository, and the older engine in the libSQL repository.