SQLite no .NET MAUI: Guia Completo para Armazenamento Local em 2026
Guia prático para usar SQLite no .NET MAUI 2026: instalação, CRUD assíncrono, EF Core, migrations em produção, criptografia com SQLCipher e sincronização com backend.
SQLite no .NET MAUI é a solução padrão para armazenamento local em apps móveis multiplataforma: um banco de dados relacional embutido, sem servidor, que roda no dispositivo e persiste dados entre execuções sem depender de conexão. Neste guia prático, vou mostrar como configurar SQLite em .NET MAUI usando sqlite-net-pcl e Microsoft.EntityFrameworkCore.Sqlite, resolver os caminhos de arquivo em Android, iOS e Windows, executar operações CRUD assíncronas, aplicar migrations sem perder dados de produção e criptografar informações sensíveis com SQLCipher. Tudo com código pronto para produção em .NET 9 e .NET 10.
SQLite roda embutido no processo do app MAUI, sem servidor, e é a única opção suportada oficialmente para bancos relacionais locais em iOS e Android.
Use sqlite-net-pcl (v1.9+) para código simples e reflexivo, ou Microsoft.EntityFrameworkCore.Sqlite quando precisar de LINQ tipado e migrations declarativas.
O caminho correto do arquivo é sempre FileSystem.AppDataDirectory. Nunca use paths hardcoded, porque eles quebram entre Android, iOS e MSIX no Windows.
Registre o SQLiteAsyncConnection como singleton no MauiProgram.cs para evitar deadlocks e reutilizar o pool de conexões.
Para dados sensíveis (tokens, credenciais, PII), use SQLCipher via SQLitePCLRaw.bundle_e_sqlcipher. A chave deve vir do SecureStorage, e jamais hardcoded no código.
EF Core no MAUI suporta migrations desde a versão 8, mas exige AOT-safe queries em iOS. Evite expressões dinâmicas em produção.
sqlite-net-pcl vs EF Core: qual escolher no .NET MAUI
Antes de escrever qualquer linha de código, a decisão mais importante é qual biblioteca vai gerenciar o SQLite dentro do app. As duas opções oficialmente suportadas em .NET MAUI 2026 são sqlite-net-pcl (mantida pela comunidade e adotada por praticamente todos os projetos legados de Xamarin.Forms) e Microsoft.EntityFrameworkCore.Sqlite, que ganhou suporte estável a mobile a partir do EF Core 8 e agora funciona sem hacks em iOS e Android com .NET 9.
A tabela abaixo resume as diferenças que realmente importam quando você está escolhendo entre as duas para um app de produção:
Critério
sqlite-net-pcl
EF Core SQLite
Tamanho do binário adicionado
~180 KB
~2,4 MB (com trimming: ~900 KB)
Curva de aprendizado
Baixa. CRUD em ~10 linhas
Média. DbContext, DbSet, migrations
Queries LINQ tipadas
Parcial (lambda simples)
Completa (joins, group by, includes)
Migrations automáticas
Manual (CREATE/ALTER)
Add-Migration, dotnet ef
Compatibilidade AOT em iOS
Ótima (usa reflexão mínima)
Boa desde EF Core 8, requer atenção
Suporte a relacionamentos
Manual (foreign keys)
Nativo (navigation properties)
Ideal para
Apps CRUD simples, migração de Xamarin
Domínios ricos, muitos relacionamentos
Honestamente, na minha experiência mantendo apps MAUI em produção, a regra prática é: se o app tem menos de 15 tabelas e a lógica de negócio cabe em consultas simples, sqlite-net-pcl entrega mais valor com menos código. Se o domínio é rico (com agregados, entidades relacionadas e queries complexas), EF Core paga o custo de tamanho e complexidade com produtividade e manutenibilidade a longo prazo. Este guia cobre os dois caminhos.
Como instalar e configurar sqlite-net-pcl no .NET MAUI
A configuração de SQLite em um projeto .NET MAUI leva menos de cinco minutos quando você segue a ordem correta. Comece adicionando os pacotes NuGet ao arquivo .csproj do projeto principal, não ao projeto de plataforma:
O bundle_green é a variante recomendada para uso geral porque inclui o binário SQLite nativo pré-compilado para todas as plataformas alvo do MAUI (Android, iOS, macOS Catalyst e Windows). Evite bundle_e_sqlite3 em novos projetos, já que ele é o binário legado do Xamarin e não recebe mais correções de segurança desde 2024. A lista oficial de bundles está descrita no repositório do SQLitePCL.raw no GitHub, e vale conferir de tempos em tempos.
Em seguida, defina a entidade que será persistida. O sqlite-net-pcl usa atributos para mapear a classe para uma tabela:
using SQLite;
public class TarefaItem
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[MaxLength(200), NotNull]
public string Titulo { get; set; } = string.Empty;
[Indexed]
public bool Concluida { get; set; }
public DateTime CriadoEm { get; set; }
}
Agora crie o serviço que encapsula o acesso ao banco. Registrar este serviço como singleton é essencial. O SQLiteAsyncConnection mantém internamente uma fila serializada de operações, e criar múltiplas instâncias causa contenção e, em Android, o infame erro database is locked. Já apanhei bastante disso num app que criava a conexão dentro de cada ViewModel; a solução foi mover tudo para um único repositório injetado.
using SQLite;
public interface ITarefaRepository
{
Task<List<TarefaItem>> ListarAsync();
Task<int> SalvarAsync(TarefaItem item);
Task<int> RemoverAsync(TarefaItem item);
}
public class TarefaRepository : ITarefaRepository
{
private SQLiteAsyncConnection? _db;
private async Task InicializarAsync()
{
if (_db is not null) return;
var caminho = Path.Combine(FileSystem.AppDataDirectory, "tarefas.db3");
_db = new SQLiteAsyncConnection(caminho,
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
await _db.CreateTableAsync<TarefaItem>();
}
public async Task<List<TarefaItem>> ListarAsync()
{
await InicializarAsync();
return await _db!.Table<TarefaItem>().OrderByDescending(t => t.CriadoEm).ToListAsync();
}
public async Task<int> SalvarAsync(TarefaItem item)
{
await InicializarAsync();
return item.Id == 0
? await _db!.InsertAsync(item)
: await _db!.UpdateAsync(item);
}
public async Task<int> RemoverAsync(TarefaItem item)
{
await InicializarAsync();
return await _db!.DeleteAsync(item);
}
}
Registre o repositório no MauiProgram.cs junto com as suas ViewModels. O padrão de injeção de dependências que uso aqui é o mesmo do nosso guia de MVVM com CommunityToolkit no .NET MAUI, garantindo que o repositório seja único no ciclo de vida do app:
Onde o SQLite armazena os dados em cada plataforma
Uma das dúvidas mais comuns entre desenvolvedores vindos de Xamarin.Forms é onde exatamente o arquivo .db3 vive em cada plataforma. A resposta correta é sempre a mesma: use FileSystem.AppDataDirectory do namespace Microsoft.Maui.Storage. Essa propriedade abstrai as diferenças entre sistemas e garante que o banco fique em um diretório privado do app, incluído em backups automáticos quando apropriado.
Nos bastidores, o caminho resolvido varia bastante entre plataformas:
Android:/data/user/0/<package-id>/files. Acessível apenas ao app, e incluído em Auto Backup do Google Drive por padrão.
iOS:~/Library/ dentro do sandbox do app. É sincronizado com iCloud a menos que você marque o arquivo com NSURLIsExcludedFromBackupKey.
Windows (MSIX):%LOCALAPPDATA%\Packages\<package-family>\LocalState. Isolado por MSIX, persiste entre atualizações do app.
Mac Catalyst:~/Library/Containers/<bundle-id>/Data/Library, sandbox equivalente ao iOS.
Para depurar o conteúdo do banco durante o desenvolvimento, você pode extraí-lo do dispositivo. Em Android com um emulador, o comando é direto:
Abra o arquivo resultante em ferramentas como DB Browser for SQLite ou na extensão SQLite do VS Code. Em iOS, use o Xcode → Devices → Download Container para extrair o sandbox inteiro. (Sim, o Xcode ainda usa esse fluxo esquisito, mas funciona.)
Operações CRUD assíncronas com sqlite-net-pcl
Todas as operações do sqlite-net-pcl em .NET MAUI devem ser assíncronas. A biblioteca oferece dois modos de conexão, síncrono (SQLiteConnection) e assíncrono (SQLiteAsyncConnection), mas o síncrono bloqueia a thread da UI e é o principal responsável por ANRs (Application Not Responding) em Android e freezes visíveis em iOS. Use sempre a versão assíncrona no MAUI.
Além do CRUD básico já mostrado no repositório, algumas operações merecem atenção especial. Consultas parametrizadas com QueryAsync são a forma mais segura de executar SQL cru quando o LINQ da biblioteca não expressa o que você precisa:
public async Task<List<TarefaItem>> BuscarPorTextoAsync(string texto)
{
await InicializarAsync();
return await _db!.QueryAsync<TarefaItem>(
"SELECT * FROM TarefaItem WHERE Titulo LIKE ? ORDER BY CriadoEm DESC",
$"%{texto}%");
}
Os parâmetros posicionais ? protegem contra SQL injection, o que é essencial quando o texto vem de um Entry do usuário. Nunca concatene strings em SQL. Mesmo em apps locais, um input malicioso pode corromper o banco.
Para inserções em lote (importação inicial, sincronização), use InsertAllAsync dentro de uma transação. A diferença de performance é dramática. Em benchmarks meus, inserir 10 mil registros um por um levou 42 segundos; a mesma operação em transação levou 380 ms:
Note que RunInTransactionAsync recebe um SQLiteConnection síncrono dentro do lambda, e isso é intencional. A biblioteca serializa toda a transação em uma única operação atômica na fila assíncrona, então dentro dela você usa os métodos síncronos sem penalidade.
Como usar Entity Framework Core com SQLite no MAUI
Quando o domínio do app cresce (múltiplos agregados, relacionamentos, queries complexas com joins), vale trocar sqlite-net-pcl por Entity Framework Core. Desde a versão 8, o EF Core suporta oficialmente .NET MAUI em iOS e Android, incluindo compilação AOT. Comece adicionando o pacote:
Defina um DbContext igual ao que você faria em uma aplicação ASP.NET, mas com uma diferença crítica: o construtor deve resolver o caminho do banco via FileSystem.AppDataDirectory em vez de string de conexão hardcoded:
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public DbSet<TarefaItem> Tarefas => Set<TarefaItem>();
public DbSet<Categoria> Categorias => Set<Categoria>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
var caminho = Path.Combine(FileSystem.AppDataDirectory, "app.db");
options.UseSqlite($"Filename={caminho}");
}
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<TarefaItem>()
.HasOne(t => t.Categoria)
.WithMany(c => c.Tarefas)
.HasForeignKey(t => t.CategoriaId);
}
}
Registre o DbContext como scoped ou transient, nunca singleton. O EF Core mantém estado por instância e reutilizar entre threads causa exceções sutis:
builder.Services.AddDbContext<AppDbContext>(options =>
{
var caminho = Path.Combine(FileSystem.AppDataDirectory, "app.db");
options.UseSqlite($"Filename={caminho}");
});
Na primeira execução, você precisa aplicar o schema. Duas opções: EnsureCreated() (cria tudo do zero, sem histórico) ou Database.Migrate() (aplica migrations pendentes). Para MVPs e protótipos, EnsureCreated basta; para produção, sempre migrations. Coloque a chamada no início do App.xaml.cs:
public App(IServiceProvider services)
{
InitializeComponent();
using var scope = services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<AppDbContext>();
ctx.Database.Migrate();
MainPage = new AppShell();
}
Migrations e evolução do schema em produção
Evoluir o schema de um banco SQLite instalado em milhares de dispositivos é o cenário mais arriscado do ciclo de vida de um app móvel. Diferente de um servidor central, você não pode aplicar um ALTER TABLE e esperar que "todos" atualizem. Cada usuário faz o upgrade no seu tempo, e alguns pulam versões inteiras.
Com o EF Core, o fluxo padrão usa a CLI dotnet ef. Gere a primeira migration em um projeto de biblioteca compartilhada (não no head do MAUI, que dificulta a compilação em design-time):
dotnet ef migrations add InicialSchema --project MinhaApp.Data
dotnet ef migrations add AdicionarCampoPrioridade --project MinhaApp.Data
Cada migration gera arquivos C# que são compilados dentro do app e aplicados sequencialmente na primeira execução após o update. Isso significa que um usuário pulando da v1 para a v5 vai aplicar migrations 2, 3, 4 e 5 em ordem, sem intervenção.
Com sqlite-net-pcl, as migrations são manuais. O padrão que funciona bem em produção é manter uma tabela de metadados de versão:
public class DbVersion
{
[PrimaryKey]
public int Versao { get; set; }
public DateTime AplicadaEm { get; set; }
}
private async Task AplicarMigrationsAsync()
{
await _db!.CreateTableAsync<DbVersion>();
var atual = await _db.Table<DbVersion>()
.OrderByDescending(v => v.Versao)
.FirstOrDefaultAsync();
var versaoAtual = atual?.Versao ?? 0;
if (versaoAtual < 1)
{
await _db.ExecuteAsync("ALTER TABLE TarefaItem ADD COLUMN Prioridade INTEGER DEFAULT 0");
await _db.InsertAsync(new DbVersion { Versao = 1, AplicadaEm = DateTime.UtcNow });
}
if (versaoAtual < 2)
{
await _db.ExecuteAsync("CREATE INDEX IX_TarefaItem_Prioridade ON TarefaItem(Prioridade)");
await _db.InsertAsync(new DbVersion { Versao = 2, AplicadaEm = DateTime.UtcNow });
}
}
Duas regras que aprendi da forma difícil: (1) migrations em SQLite são limitadas, e não é possível DROP COLUMN antes da versão 3.35 do engine, então planeje o schema com cuidado; (2) sempre teste o path de upgrade em um dispositivo real com dados da versão anterior, não apenas em um banco vazio. A documentação oficial de ALTER TABLE do SQLite lista todas as limitações e workarounds. (Tenho um app em produção que, por não ter testado o upgrade com dados reais, virou brick numa versão específica do Android; nunca mais.)
Criptografia com SQLCipher para dados sensíveis
SQLite por padrão armazena tudo em texto puro. Se o app guarda tokens de autenticação, dados médicos, informações financeiras ou qualquer PII sujeita a LGPD/GDPR, você precisa criptografar o arquivo inteiro, não apenas colunas específicas. A solução padrão é SQLCipher, uma extensão do SQLite que aplica AES-256 em cada página do banco.
Substitua o pacote bundle_green pelo bundle com SQLCipher:
A chave de criptografia nunca deve ser hardcoded. Gere-a na primeira execução do app, armazene no SecureStorage do MAUI (que usa Keychain no iOS e Keystore no Android), e recupere-a a cada abertura da conexão:
private async Task<string> ObterChaveAsync()
{
var chave = await SecureStorage.GetAsync("db_key");
if (chave is null)
{
var bytes = RandomNumberGenerator.GetBytes(32);
chave = Convert.ToBase64String(bytes);
await SecureStorage.SetAsync("db_key", chave);
}
return chave;
}
private async Task InicializarAsync()
{
if (_db is not null) return;
var chave = await ObterChaveAsync();
var caminho = Path.Combine(FileSystem.AppDataDirectory, "seguro.db3");
var opcoes = new SQLiteConnectionString(caminho, true, key: chave);
_db = new SQLiteAsyncConnection(opcoes);
await _db.CreateTableAsync<TarefaItem>();
}
Um trade-off importante: SQLCipher adiciona ~15% de overhead em leituras e ~25% em escritas comparado ao SQLite puro. Em apps com muita leitura sequencial (feeds, logs, cache), considere criptografar apenas as colunas sensíveis usando System.Security.Cryptography e deixar o resto em texto puro.
Performance, concorrência e sincronização com backend
Um banco SQLite bem configurado no .NET MAUI serve confortavelmente apps com milhões de linhas. Mas configurado mal, ele trava a UI mesmo com poucas centenas de registros. Três padrões que fazem toda a diferença em produção:
WAL mode para leitura concorrente
Por padrão, SQLite serializa todas as operações. Habilitando Write-Ahead Logging (WAL), leituras podem ocorrer em paralelo com escritas, o que reduz bastante a percepção de lentidão em telas que listam dados enquanto uma sincronização roda em background:
Qualquer coluna usada em WHERE, ORDER BY ou JOIN frequente deveria ter índice. O atributo [Indexed] do sqlite-net-pcl cuida disso automaticamente; em EF Core, use HasIndex() no OnModelCreating. Um índice ausente é a causa mais comum de queries lentas em apps móveis. Cobri mais estratégias no guia de comparação entre .NET MAUI e Flutter, onde performance de armazenamento local é um dos pontos que mais pesam na escolha do framework.
Sincronização com backend
A abordagem mais confiável para manter SQLite local sincronizado com uma API remota é o padrão outbox: escreva sempre local primeiro, marque a linha como pendente, e tenha um serviço em background que empurra as mudanças para o servidor quando houver conexão. Combine com Connectivity.Current.NetworkAccess para saber quando tentar:
public async Task SalvarELocalSincronizarAsync(TarefaItem item)
{
item.SyncStatus = "Pendente";
await SalvarAsync(item);
if (Connectivity.Current.NetworkAccess == NetworkAccess.Internet)
{
try
{
await _api.EnviarAsync(item);
item.SyncStatus = "Sincronizado";
await SalvarAsync(item);
}
catch (HttpRequestException)
{
// fica como Pendente, próximo ciclo tenta novamente
}
}
}
Para casos mais elaborados (resolução de conflitos, sincronização bidirecional, delta sync), vale investigar CRDTs ou o framework de Data & Cloud Services no .NET MAUI, que documenta padrões oficiais de acesso a dados. Para bancos além de 100 MB, considere particionar por data (uma tabela por mês) e arquivar dados antigos em blob storage.
Perguntas frequentes
Qual é a diferença entre Preferences e SQLite no .NET MAUI?
Preferences é um armazenamento chave-valor para configurações simples (strings, ints, bools), equivalente a SharedPreferences no Android e NSUserDefaults no iOS. SQLite é um banco relacional completo, adequado para listas, relacionamentos e consultas. Use Preferences para preferências de usuário e feature flags; use SQLite para dados de domínio.
Como fazer backup de um banco SQLite em um app MAUI?
A forma mais simples é copiar o arquivo .db3 de FileSystem.AppDataDirectory para um destino de backup. Pode ser FileSystem.CacheDirectory, um provedor de nuvem via API (OneDrive, Google Drive), ou o próprio backend do app. Sempre feche a conexão antes de copiar para evitar arquivos corrompidos, ou use VACUUM INTO para gerar uma cópia consistente com a conexão aberta.
SQLite funciona offline no .NET MAUI?
Sim, SQLite é totalmente local e não requer conexão de rede. É justamente por isso que ele é a escolha padrão para funcionalidade offline em apps MAUI. Todo o processamento acontece no dispositivo, e a sincronização com um backend é opcional e aplicada como camada separada por cima do banco local.
O SQLite tem limite de tamanho no mobile?
Tecnicamente, SQLite suporta bancos de até 281 TB, mas na prática em mobile o limite é o espaço livre do dispositivo e o desempenho aceitável. Apps bem projetados mantêm o SQLite abaixo de 500 MB. Acima disso, considere arquivar dados antigos ou mover blobs (imagens, vídeos) para o sistema de arquivos e guardar apenas caminhos no banco.
Preciso fechar a conexão SQLite manualmente no .NET MAUI?
Não em uso normal. Se você registrar o SQLiteAsyncConnection como singleton, a conexão vive junto com o processo do app e é finalizada automaticamente quando o app é encerrado pelo SO. Fechamento manual só é necessário em cenários especiais como backup de arquivo ou migração para outra base de dados.
Guia prático de injeção de dependência no .NET MAUI 2026: lifetimes, registro em MauiProgram.cs, integração com MVVM Toolkit, testes e as armadilhas mais comuns em produção.
Guia prático para implementar autenticação biométrica em apps .NET MAUI: Face ID e Touch ID no iOS, BiometricPrompt no Android, integração com SecureStorage, fallback para PIN e os erros que mais quebram em produção.
Comparação honesta entre .NET MAUI 9 e Flutter 3.24 em 2026: performance em hardware físico, produtividade, ecossistema, tooling e critérios claros para decidir qual framework escolher para o seu próximo projecto móvel.