Base de datos SQLite en C# con Microsoft.Data.Sqlite

SQLite es una base de datos completa en un solo archivo, sin servidor ni instalación. Desde C# se usa con el paquete oficial Microsoft.Data.Sqlite y unas pocas clases que funcionan igual con cualquier otra base de datos de .NET.

El paquete y la conexión

SQLite guarda toda la base de datos en un solo archivo y no necesita servidor, así que es perfecto para herramientas, aplicaciones de escritorio y prototipos. Para C#, Microsoft mantiene el paquete Microsoft.Data.Sqlite, que ya incluye el motor de SQLite para Windows, Linux y macOS. En un archivo suelto se añade con la directiva #:package en la primera línea; en un proyecto, con dotnet add package Microsoft.Data.Sqlite.

Crear, insertar y consultar

#:package Microsoft.Data.Sqlite@10.0.14

using Microsoft.Data.Sqlite;

File.Delete("tienda.db");
using var conexion = new SqliteConnection("Data Source=tienda.db");
conexion.Open();

var crear = conexion.CreateCommand();
crear.CommandText = """
    CREATE TABLE productos (
        id     INTEGER PRIMARY KEY,
        nombre TEXT    NOT NULL,
        precio REAL    NOT NULL,
        stock  INTEGER NOT NULL DEFAULT 0
    )
    """;
crear.ExecuteNonQuery();

var insertar = conexion.CreateCommand();
insertar.CommandText = "INSERT INTO productos (nombre, precio, stock) VALUES ($nombre, $precio, $stock)";
var pNombre = insertar.Parameters.Add("$nombre", SqliteType.Text);
var pPrecio = insertar.Parameters.Add("$precio", SqliteType.Real);
var pStock = insertar.Parameters.Add("$stock", SqliteType.Integer);

foreach (var (nombre, precio, stock) in new[] { ("Café molido", 4.5, 30), ("Té verde", 3.2, 0), ("Cacao puro", 6.75, 12) })
{
    pNombre.Value = nombre;
    pPrecio.Value = precio;
    pStock.Value = stock;
    insertar.ExecuteNonQuery();
}

var consulta = conexion.CreateCommand();
consulta.CommandText = "SELECT id, nombre, precio FROM productos WHERE stock > 0 ORDER BY precio DESC";
using var lector = consulta.ExecuteReader();
while (lector.Read())
{
    Console.WriteLine($"{lector.GetInt32(0)}  {lector.GetString(1),-12} {lector.GetDouble(2),5:F2}");
}
salida3  Cacao puro    6.75
1  Café molido   4.50

El patrón se repite en todo ADO.NET, la forma estándar de .NET de hablar con bases de datos: una conexión que se abre, comandos con su SQL, ExecuteNonQuery para lo que no devuelve filas y ExecuteReader para lo que sí. El lector avanza fila a fila con Read() y cada columna se lee por su posición con el método de su tipo. El using cierra la conexión al terminar.

Los valores van en parámetros ($nombre), no pegados al SQL. Al insertar varias filas se crea el comando una vez y solo se cambian los valores, que es además más rápido.

Un solo valor: ExecuteScalar

#:package Microsoft.Data.Sqlite@10.0.14

using Microsoft.Data.Sqlite;

File.Delete("escalar.db");
using var conexion = new SqliteConnection("Data Source=escalar.db");
conexion.Open();

using (var c = conexion.CreateCommand())
{
    c.CommandText = "CREATE TABLE notas (id INTEGER PRIMARY KEY, texto TEXT)";
    c.ExecuteNonQuery();
}

using var insertar = conexion.CreateCommand();
insertar.CommandText = "INSERT INTO notas (texto) VALUES ($t) RETURNING id";
insertar.Parameters.AddWithValue("$t", "Comprar café");
long id = (long)insertar.ExecuteScalar()!;
Console.WriteLine($"Nueva nota con id {id}");

using var contar = conexion.CreateCommand();
contar.CommandText = "SELECT COUNT(*) FROM notas";
Console.WriteLine($"Hay {(long)contar.ExecuteScalar()!} nota(s)");
salidaNueva nota con id 1
Hay 1 nota(s)

ExecuteScalar devuelve la primera columna de la primera fila, como object. SQLite entrega los enteros como long, de ahí la conversión. RETURNING id (SQLite 3.35 o posterior) devuelve el identificador de la fila que se acaba de insertar.

Transacciones: todo o nada

#:package Microsoft.Data.Sqlite@10.0.14

using Microsoft.Data.Sqlite;

File.Delete("pedidos.db");
using var conexion = new SqliteConnection("Data Source=pedidos.db");
conexion.Open();
using (var c = conexion.CreateCommand())
{
    c.CommandText = "CREATE TABLE cuentas (nombre TEXT PRIMARY KEY, saldo INTEGER NOT NULL CHECK (saldo >= 0));" +
                    "INSERT INTO cuentas VALUES ('Ana', 100), ('Luis', 20);";
    c.ExecuteNonQuery();
}

Transferir(conexion, "Ana", "Luis", 30);
Transferir(conexion, "Luis", "Ana", 500);

using var ver = conexion.CreateCommand();
ver.CommandText = "SELECT nombre, saldo FROM cuentas ORDER BY nombre";
using var r = ver.ExecuteReader();
while (r.Read()) Console.WriteLine($"{r.GetString(0)}: {r.GetInt64(1)}");

static void Transferir(SqliteConnection conexion, string de, string a, int cantidad)
{
    using var tx = conexion.BeginTransaction();
    try
    {
        var sumar = conexion.CreateCommand();
        sumar.CommandText = "UPDATE cuentas SET saldo = saldo + $c WHERE nombre = $a";
        sumar.Parameters.AddWithValue("$c", cantidad);
        sumar.Parameters.AddWithValue("$a", a);
        sumar.ExecuteNonQuery();

        var restar = conexion.CreateCommand();
        restar.CommandText = "UPDATE cuentas SET saldo = saldo - $c WHERE nombre = $de";
        restar.Parameters.AddWithValue("$c", cantidad);
        restar.Parameters.AddWithValue("$de", de);
        restar.ExecuteNonQuery();

        tx.Commit();
        Console.WriteLine($"{de} -> {a}: {cantidad} hecha");
    }
    catch (SqliteException ex)
    {
        tx.Rollback();
        Console.WriteLine($"{de} -> {a}: {cantidad} deshecha ({ex.Message})");
    }
}
salidaAna -> Luis: 30 hecha
Luis -> Ana: 500 deshecha (SQLite Error 19: 'CHECK constraint failed: saldo >= 0'.)
Ana: 70
Luis: 50

La segunda transferencia suma 500 a Ana y luego intenta dejar a Luis en negativo, lo que prohíbe la restricción CHECK. Como las dos operaciones están en la misma transacción, el Rollback deshace también la suma: los saldos quedan como tras la primera. Las transacciones también aceleran muchísimo las inserciones masivas: SQLite confirma en disco una vez por transacción, no por fila.

Por qué siempre parámetros

#:package Microsoft.Data.Sqlite@10.0.14

using Microsoft.Data.Sqlite;

File.Delete("usuarios.db");
using var conexion = new SqliteConnection("Data Source=usuarios.db");
conexion.Open();
using (var c = conexion.CreateCommand())
{
    c.CommandText = "CREATE TABLE usuarios (nombre TEXT, clave TEXT);" +
                    "INSERT INTO usuarios VALUES ('ana', 's3creta'), ('luis', 'otra');";
    c.ExecuteNonQuery();
}

string escrito = "x' OR '1'='1";   // lo que alguien teclea en el formulario

var mal = conexion.CreateCommand();
mal.CommandText = $"SELECT COUNT(*) FROM usuarios WHERE nombre = '{escrito}'";
Console.WriteLine($"Pegando el texto:    {mal.ExecuteScalar()} usuario(s)");

var bien = conexion.CreateCommand();
bien.CommandText = "SELECT COUNT(*) FROM usuarios WHERE nombre = $nombre";
bien.Parameters.AddWithValue("$nombre", escrito);
Console.WriteLine($"Con un parámetro:    {bien.ExecuteScalar()} usuario(s)");
salidaPegando el texto:    2 usuario(s)
Con un parámetro:    0 usuario(s)
Inyección de SQL. Al pegar lo que escribe el usuario dentro del SQL, su comilla cierra la tuya y el OR '1'='1' hace que la condición sea siempre verdadera: la consulta devuelve todos los usuarios. Con un formulario de inicio de sesión, eso es entrar sin contraseña. Con un parámetro, el texto es solo un valor, por raro que sea, y no encuentra a nadie. La interpolación $"..." de C# es muy cómoda, pero nunca para construir SQL.
Y si no quieres escribir SQL a mano, las dos opciones habituales en .NET son Dapper, que convierte las filas en objetos con una línea (conexion.Query<Producto>("SELECT ...")), y Entity Framework Core, el ORM de Microsoft, que genera el SQL a partir de consultas LINQ.

Todo el código de esta página se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.