How to Connect to SQLite from C# (.NET)

8 min readSQLite

SQLite in .NET comes down to two providers: Microsoft.Data.Sqlite and System.Data.SQLite. For new code, Microsoft.Data.Sqlite is the default choice. It is the lightweight, cross-platform ADO.NET provider the Entity Framework Core team built and maintains, and it bundles the native SQLite library so there is nothing to install at the OS level. System.Data.SQLite is the older provider from the SQLite authors themselves; it carries more features (encryption, a full LINQ-to-Entities legacy stack) but a heavier footprint. This guide starts with raw ADO.NET on Microsoft.Data.Sqlite, then covers the SQLite-specific traps (file creation, locking, WAL), then layers on Dapper and EF Core.

The two providers

LibraryMaintainerWhen to use
Microsoft.Data.Sqlite.NET / EF Core teamNew code. Lightweight, cross-platform, bundles native SQLite.
System.Data.SQLiteSQLite developers (Hipp et al.)You need built-in encryption (SEE/legacy), or you are on a long-lived .NET Framework codebase already using it.

Unless you have a specific reason to pick System.Data.SQLite, use Microsoft.Data.Sqlite. The rest of this guide uses it; a short System.Data.SQLite section is at the end.

Adding the package

dotnet add package Microsoft.Data.Sqlite

Microsoft.Data.Sqlite 10.0 (current stable as of June 2026) targets modern .NET. The Microsoft.Data.Sqlite package pulls in the bundled native library (SQLitePCLRaw) automatically, so it works out of the box on Windows, Linux, and macOS. There is no separate "install SQLite" step.

A first connection with ADO.NET

SQLite is a file, not a server, so the connection string is mostly a path. The pattern is the standard ADO.NET one: SqliteConnection to SqliteCommand to a reader.

using Microsoft.Data.Sqlite;
 
var connString = "Data Source=app.db";
 
using var conn = new SqliteConnection(connString);
conn.Open();
 
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, email FROM users WHERE active = $active";
cmd.Parameters.AddWithValue("$active", 1);
 
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
    var id = reader.GetInt64(0);
    var email = reader.GetString(1);
    Console.WriteLine($"{id}: {email}");
}

Note GetInt64: SQLite's integer type is 64-bit, so map it to long, not int. Async methods (OpenAsync, ExecuteReaderAsync, ReadAsync) exist and are worth using in a server app, but for SQLite the I/O is a local file rather than a network round trip, so the win is smaller than with a client/server database. Still use them in ASP.NET to avoid blocking thread pool threads.

The file-creation trap

By default, opening a connection to a path that does not exist creates an empty database file. That means a typo in the path silently gives you a brand-new, empty database instead of an error, and your queries fail with "no such table".

If you want to fail loudly when the file is missing, set the open mode:

var connString = new SqliteConnectionStringBuilder
{
    DataSource = "app.db",
    Mode = SqliteOpenMode.ReadWrite  // do NOT create if missing
}.ToString();

SqliteOpenMode options: ReadWriteCreate (default, creates if missing), ReadWrite, ReadOnly, and Memory. For an in-memory database use Mode=Memory (data lives only as long as the connection is open; see the connection-lifetime note below).

Parameters: never concatenate

Microsoft.Data.Sqlite accepts $name, @name, and :name placeholders. $name is the SQLite-native form and the one the provider recommends. Bind values rather than building SQL strings:

cmd.CommandText = "INSERT INTO users (email, active) VALUES ($email, $active)";
cmd.Parameters.AddWithValue("$email", "a@example.com");
cmd.Parameters.AddWithValue("$active", 1);
cmd.ExecuteNonQuery();

SQLite has no bool type; store booleans as 0/1 integers. There is also no native DateTime: Microsoft.Data.Sqlite stores DateTime as TEXT in ISO 8601 format by default, which sorts and compares correctly. Just be consistent, and read it back with GetDateTime.

Getting the inserted row id

SQLite exposes the last inserted rowid through last_insert_rowid(), or you can use a RETURNING clause (SQLite 3.35+, which the bundled library is well past):

cmd.CommandText = "INSERT INTO users (email) VALUES ($email) RETURNING id";
cmd.Parameters.AddWithValue("$email", "a@example.com");
var newId = (long)cmd.ExecuteScalar()!;

Transactions

Wrapping many writes in a single transaction is the single biggest performance lever in SQLite. Without one, each INSERT is its own transaction with its own disk sync, which is slow. Batch them:

using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "INSERT INTO events (name) VALUES ($name)";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
cmd.Parameters.Add(p);
 
foreach (var name in names)
{
    p.Value = name;
    cmd.ExecuteNonQuery();
}
tx.Commit();

Reuse the command and just swap the parameter value inside the loop. Tens of thousands of inserts per second per transaction is normal; the same inserts without a transaction can be hundreds of times slower.

"database is locked": WAL and busy_timeout

SQLite allows one writer at a time. If a second connection tries to write while another write is in progress, you get SQLITE_BUSY surfaced as a SqliteException with "database is locked". Two settings handle the common cases:

using var conn = new SqliteConnection("Data Source=app.db");
conn.Open();
 
using (var pragma = conn.CreateCommand())
{
    pragma.CommandText = "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;";
    pragma.ExecuteNonQuery();
}
  • WAL (write-ahead logging) lets readers run concurrently with a writer, which removes most contention in a read-heavy app. It is a persistent setting on the database file, so you only need to set it once, but setting it per-connection is harmless.
  • busy_timeout tells SQLite to wait (here, 5000 ms) for a lock to clear instead of failing immediately. Without it, a momentary lock throws right away.

WAL plus a busy timeout resolves the large majority of "database is locked" reports. It does not make SQLite a high-concurrency multi-writer database; if you genuinely need many concurrent writers, that is the signal to move to a client/server database.

Connection lifetime and pooling

Microsoft.Data.Sqlite pools connections by default, so the open/close cost of file-backed databases is cheap and the standard using pattern is correct: open late, dispose early.

One exception: an in-memory database (Mode=Memory) is destroyed when its last connection closes. If you open, write, and close, the data is gone. To keep an in-memory database alive across operations, either hold one connection open for the lifetime you need, or use a shared cache (Data Source=...;Mode=Memory;Cache=Shared) and keep at least one connection open.

Dapper

Dapper works on any ADO.NET connection, so it layers onto SqliteConnection directly. Add it:

dotnet add package Dapper
using Dapper;
using Microsoft.Data.Sqlite;
 
using var conn = new SqliteConnection("Data Source=app.db");
 
var users = conn.Query<User>(
    "SELECT id, email FROM users WHERE active = $active",
    new { active = 1 });
 
record User(long Id, string Email);

Dapper opens the connection if it is closed and maps columns to your type by name. Dapper 2.1.79 is current as of June 2026.

Entity Framework Core

For a full ORM, the EF Core SQLite provider sits on Microsoft.Data.Sqlite:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
using Microsoft.EntityFrameworkCore;
 
public class AppDb : DbContext
{
    public DbSet<User> Users => Set<User>();
 
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=app.db");
}
 
public class User
{
    public long Id { get; set; }
    public string Email { get; set; } = "";
}

Match the EF Core provider's major version to your other EF Core packages: provider 10.x goes with EF Core 10.x. Be aware SQLite has a deliberately limited type and schema system, so some EF Core migrations (dropping a column, altering a column type) require a table rebuild that EF Core handles for you but that you should understand before relying on it in production.

System.Data.SQLite (the other provider)

If you specifically need it (encryption, or an existing codebase):

dotnet add package System.Data.SQLite.Core
using System.Data.SQLite;
 
using var conn = new SQLiteConnection("Data Source=app.db;Version=3;");
conn.Open();
using var cmd = new SQLiteCommand("SELECT id, email FROM users", conn);
using var reader = cmd.ExecuteReader();

System.Data.SQLite.Core 1.0.119 is current as of June 2026. The class names use SQLite (capitalized), the connection string carries a Version=3 segment, and the default parameter prefix is @. The API shape is otherwise familiar. It tracks SQLite releases closely and supports the SQLite Encryption Extension, which Microsoft.Data.Sqlite does not bundle.

Common errors

ErrorCauseFix
no such table after a path typoWrong path created a new empty fileUse Mode=ReadWrite to fail on missing file; check the resolved absolute path
database is locked (SQLITE_BUSY)Concurrent writerPRAGMA journal_mode=WAL and PRAGMA busy_timeout
In-memory data disappearsLast connection closedKeep one connection open, or use Cache=Shared
InvalidCastException reading an integerRead SQLite INTEGER as intSQLite integers are 64-bit; use GetInt64/long
Native library load failureWrong package on a trimmed/AOT buildUse the bundled Microsoft.Data.Sqlite (not .Core without a SQLitePCLRaw bundle)

For loading data into SQLite from files, see our guide on importing CSV to SQLite. For querying patterns once connected, see SQLite window functions and CTEs in SQLite.

Mako connects to SQLite, PostgreSQL, MySQL, and more with AI-powered autocomplete for writing and exploring queries. Try it free at mako.ai.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.