ecto_libsql is an (unofficial) Elixir Ecto database adapter for LibSQL and Turso, built with Rust NIFs. It supports local libSQL/SQLite files, remote replica with synchronisation, and remote only Turso databases.
Add ecto_libsql to your dependencies in mix.exs:
def deps do [ {:ecto_libsql, "~> 0.5.0"} ] end# Configure your repo config :my_app, MyApp.Repo, adapter: Ecto.Adapters.LibSql, database: "my_app.db" # Define your repo defmodule MyApp.Repo do use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.LibSql end # Use Ecto as normal defmodule MyApp.User do use Ecto.Schema schema "users" do field :name, :string field :email, :string timestamps() end end # CRUD operations {:ok, user} = MyApp.Repo.insert(%MyApp.User{name: "Alice", email: "alice@example.com"}) users = MyApp.Repo.all(MyApp.User)For lower-level control, you can use the DBConnection interface directly:
# Local database {:ok, conn} = DBConnection.start_link(EctoLibSql, database: "local.db") # Remote Turso database {:ok, conn} = DBConnection.start_link(EctoLibSql, uri: "libsql://your-db.turso.io", auth_token: "your-token" ) # Embedded replica (local database synced with remote) {:ok, conn} = DBConnection.start_link(EctoLibSql, database: "local.db", uri: "libsql://your-db.turso.io", auth_token: "your-token", sync: true )Connection Modes
- Local SQLite files
- Remote LibSQL/Turso servers
- Embedded replicas with automatic or manual synchronisation
Core Functionality
- Parameterised queries with safe parameter binding
- Prepared statements
- Transactions with multiple isolation levels (deferred, immediate, exclusive)
- Batch operations (transactional and non-transactional)
- Metadata access (last insert ID, row counts, etc.)
Advanced Features
- Vector similarity search
- Database encryption (AES-256-CBC for local and embedded replica databases)
- WebSocket and HTTP protocols
- Cursor-based streaming for large result sets (via DBConnection interface)
Note: Ecto Repo.stream() is not yet implemented. For streaming large datasets, use the DBConnection cursor interface directly (see examples in AGENTS.md).
Reliability
- Production-ready error handling: All Rust NIF errors return proper Elixir error tuples instead of crashing the BEAM VM
- Graceful degradation: Invalid operations (bad connection IDs, missing resources) return
{:error, message}for proper supervision tree handling
- API Documentation: https://hexdocs.pm/ecto_libsql
- LLM / AGENT Guide: AGENTS.md
- Changelog: CHANGELOG.md
- Migration Guide: ECTO_MIGRATION_GUIDE.md
# Setup defmodule MyApp.Repo do use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.LibSql end defmodule MyApp.User do use Ecto.Schema schema "users" do field :name, :string field :email, :string field :age, :integer timestamps() end end # Create {:ok, user} = MyApp.Repo.insert(%MyApp.User{ name: "Alice", email: "alice@example.com", age: 30 }) # Read user = MyApp.Repo.get(MyApp.User, 1) users = MyApp.Repo.all(MyApp.User) # Update user |> Ecto.Changeset.change(age: 31) |> MyApp.Repo.update() # Delete MyApp.Repo.delete(user)import Ecto.Query # Filter and order adults = MyApp.User |> where([u], u.age >= 18) |> order_by([u], desc: u.inserted_at) |> MyApp.Repo.all() # Aggregations count = MyApp.User |> where([u], u.age >= 18) |> MyApp.Repo.aggregate(:count) avg_age = MyApp.Repo.aggregate(MyApp.User, :avg, :age)MyApp.Repo.transaction(fn -> {:ok, user1} = MyApp.Repo.insert(%MyApp.User{name: "Bob", email: "bob@example.com"}) {:ok, user2} = MyApp.Repo.insert(%MyApp.User{name: "Carol", email: "carol@example.com"}) %{user1: user1, user2: user2} end)For lower-level control, use the DBConnection interface:
{:ok, conn} = DBConnection.start_link(EctoLibSql, database: "test.db") # Create table DBConnection.execute(conn, %EctoLibSql.Query{ statement: "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)" }, []) # Insert with parameters DBConnection.execute(conn, %EctoLibSql.Query{ statement: "INSERT INTO users (name) VALUES (?)" }, ["Alice"]) # Query data {:ok, _query, result} = DBConnection.execute(conn, %EctoLibSql.Query{ statement: "SELECT * FROM users WHERE name = ?" }, ["Alice"]) IO.inspect(result.rows) # [[1, "Alice"]]DBConnection.transaction(conn, fn conn -> DBConnection.execute(conn, %EctoLibSql.Query{ statement: "INSERT INTO users (name) VALUES (?)" }, ["Bob"]) DBConnection.execute(conn, %EctoLibSql.Query{ statement: "INSERT INTO users (name) VALUES (?)" }, ["Carol"]) end)# Prepare once, execute many times {:ok, state} = EctoLibSql.connect(database: "test.db") {:ok, stmt_id} = EctoLibSql.Native.prepare(state, "SELECT * FROM users WHERE id = ?") {:ok, result1} = EctoLibSql.Native.query_stmt(state, stmt_id, [1]) {:ok, result2} = EctoLibSql.Native.query_stmt(state, stmt_id, [2]) :ok = EctoLibSql.Native.close_stmt(stmt_id){:ok, state} = EctoLibSql.connect(database: "test.db") # Execute multiple statements together statements = [ {"INSERT INTO users (name) VALUES (?)", ["Dave"]}, {"INSERT INTO users (name) VALUES (?)", ["Eve"]}, {"UPDATE users SET name = ? WHERE id = ?", ["David", 1]} ] # Non-transactional (each statement independent) {:ok, results} = EctoLibSql.Native.batch(state, statements) # Transactional (all-or-nothing) {:ok, results} = EctoLibSql.Native.batch_transactional(state, statements){:ok, state} = EctoLibSql.connect(database: "vectors.db") # Create table with vector column (3 dimensions, f32 precision) vector_type = EctoLibSql.Native.vector_type(3, :f32) EctoLibSql.handle_execute( "CREATE TABLE items (id INTEGER, embedding #{vector_type})", [], [], state ) # Insert vector vec = EctoLibSql.Native.vector([1.0, 2.0, 3.0]) EctoLibSql.handle_execute( "INSERT INTO items VALUES (?, vector(?))", [1, vec], [], state ) # Find similar vectors (cosine distance) query_vector = [1.5, 2.1, 2.9] distance_fn = EctoLibSql.Native.vector_distance_cos("embedding", query_vector) {:ok, _query, results, _} = EctoLibSql.handle_execute( "SELECT id FROM items ORDER BY #{distance_fn} LIMIT 10", [], [], state )# Encrypted local database {:ok, conn} = DBConnection.start_link(EctoLibSql, database: "encrypted.db", encryption_key: "your-secret-key-must-be-at-least-32-characters" ) # Encrypted embedded replica {:ok, conn} = DBConnection.start_link(EctoLibSql, database: "encrypted.db", uri: "libsql://your-db.turso.io", auth_token: "your-token", encryption_key: "your-secret-key-must-be-at-least-32-characters", sync: true )When using embedded replica mode (sync: true), the library automatically handles synchronisation between your local database and Turso cloud. However, you can also trigger manual sync when needed.
# Automatic sync is enabled with sync: true {:ok, state} = EctoLibSql.connect( database: "local.db", uri: "libsql://your-db.turso.io", auth_token: "your-token", sync: true # Automatic sync enabled ) # Writes and reads work normally - sync happens automatically EctoLibSql.handle_execute("INSERT INTO users (name) VALUES (?)", ["Alice"], [], state) EctoLibSql.handle_execute("SELECT * FROM users", [], [], state)How automatic sync works:
- Initial sync happens when you first connect
- Changes are synced automatically in the background
- You don't need to call
sync/1in most applications
For specific use cases, you can manually trigger synchronisation:
# Force immediate sync after critical operation EctoLibSql.handle_execute("INSERT INTO orders (total) VALUES (?)", [1000.00], [], state) {:ok, _} = EctoLibSql.Native.sync(state) # Ensure synced to cloud immediately # Before shutdown - ensure all changes are persisted {:ok, _} = EctoLibSql.Native.sync(state) :ok = EctoLibSql.disconnect([], state) # Coordinate between multiple replicas {:ok, _} = EctoLibSql.Native.sync(replica1) # Push local changes {:ok, _} = EctoLibSql.Native.sync(replica2) # Pull those changes on another replicaWhen to use manual sync:
- Critical operations: Immediately after writes that must be durable
- Before shutdown: Ensuring all local changes reach the cloud
- Coordinating replicas: When multiple replicas need consistent data immediately
- After batch operations: Following bulk inserts/updates
When you DON'T need manual sync:
- Normal application reads/writes (automatic sync handles this)
- Most CRUD operations (background sync is sufficient)
- Development and testing (automatic sync is fine)
You can disable automatic sync and rely entirely on manual control:
# Disable automatic sync {:ok, state} = EctoLibSql.connect( database: "local.db", uri: "libsql://your-db.turso.io", auth_token: "your-token", sync: false # Manual sync only ) # Make local changes (not synced yet) EctoLibSql.handle_execute("INSERT INTO users (name) VALUES (?)", ["Alice"], [], state) # Manually synchronise when ready {:ok, _} = EctoLibSql.Native.sync(state)This is useful for offline-first applications or when you want explicit control over when data syncs.
| Option | Type | Description |
|---|---|---|
database | string | Path to local SQLite database file |
uri | string | Remote LibSQL server URI (e.g., libsql://... or wss://...) |
auth_token | string | Authentication token for remote connections |
sync | boolean | Enable automatic synchronisation for embedded replicas |
encryption_key | string | Encryption key (32+ characters) for local database |
The adapter automatically detects the connection mode based on the options provided:
Only database specified - stores data in a local SQLite file:
config :my_app, MyApp.Repo, adapter: Ecto.Adapters.LibSql, database: "my_app.db"uri and auth_token specified - connects directly to Turso cloud:
config :my_app, MyApp.Repo, adapter: Ecto.Adapters.LibSql, uri: "libsql://your-database.turso.io", auth_token: System.get_env("TURSO_AUTH_TOKEN")All of database, uri, auth_token, and sync specified - local file with cloud synchronisation:
config :my_app, MyApp.Repo, adapter: Ecto.Adapters.LibSql, database: "replica.db", uri: "libsql://your-database.turso.io", auth_token: System.get_env("TURSO_AUTH_TOKEN"), sync: trueThis mode provides microsecond read latency (local file) with automatic cloud backup. Synchronisation happens automatically in the background - see the Embedded Replica Synchronisation section for details on sync behaviour and manual sync control.
Control transaction locking behaviour:
# Deferred (default) - locks acquired on first write {:ok, state} = EctoLibSql.Native.begin(state, behavior: :deferred) # Immediate - acquire write lock immediately {:ok, state} = EctoLibSql.Native.begin(state, behavior: :immediate) # Read-only - read lock only {:ok, state} = EctoLibSql.Native.begin(state, behavior: :read_only)# Get last inserted row ID rowid = EctoLibSql.Native.get_last_insert_rowid(state) # Get number of rows changed by last statement changes = EctoLibSql.Native.get_changes(state) # Get total rows changed since connection opened total = EctoLibSql.Native.get_total_changes(state) # Check if in autocommit mode (not in transaction) autocommit? = EctoLibSql.Native.get_is_autocommit(state)Apache 2.0
This library is a fork of libsqlex by danawanb, extended from a DBConnection adapter to a full Ecto adapter with additional features including vector similarity search, database encryption, batch operations, prepared statements, and comprehensive documentation.