Skip to content

Fisher: A Document Database for Anywhere SQLite is a Good Choice

Jeremy Miller1st October 2026
FisherSQLiteDocument DatabaseWeaselWolverineEF CoreCritter Stack
Fisher

Fisher is part of the Critter Stack and is covered by JasperFx Software support plans right alongside Marten, Wolverine, and Polecat. If you're building something on top of it and want a safety net, we'd love to talk.

When we introduced Fisher back in August, I mostly talked about it as "the Critter Stack on SQLite," and most of the attention since has gone to the Event Sourcing half of it. That's pretty normal for us. Event Sourcing is what most folks know Marten for these days, and it's easy to forget that Marten started its life as nothing more than a way to use PostgreSQL as a document database so my shop at the time could get off of RavenDB in a hurry.

Fisher has that same split personality. It's a very capable event store, but it's also a document database where the entire "database server" is a file sitting inside your own process. I think that second half is going to be useful to a lot more people than the first, including plenty of folks who will never care one bit about Event Sourcing.

So here's the claim for this post: if SQLite is a good choice for your application's storage, Fisher is very likely the most productive way to use SQLite from .NET. I'm going to pick on EF Core a little bit to make that case, because EF Core is what most .NET developers are going to reach for by default.

Where SQLite is the right answer ​

The SQLite team has a great page on appropriate uses for SQLite, and it's worth a read if all you've ever heard is "SQLite is the thing you use for demos." Translating that list into the .NET world, I'd reach for embedded storage for:

  • Desktop applications and CLI tools that need real persistence without asking the user to install anything
  • Edge, kiosk, point of sale, or on premises deployments where there isn't going to be a database server and nobody on site to run one
  • Single node internal services where standing up PostgreSQL or SQL Server would be more operational work than the application itself
  • Applications that have to keep working when the network doesn't
  • Per tenant or per customer isolated data, where "one file per tenant" is a feature
  • Anywhere you were about to write your own JSON files to disk and hope for the best

That last one is my favorite. If you've ever hand rolled a settings.json plus a couple "cache" files plus some locking code around them, you already wanted an embedded document database. You just didn't have one handy.

We built Fisher in the first place for exactly this reason -- and to use in our own tools. We wanted people to be able to get up and going with CritterWatch without provisioning a database server, so the SQLite flavor of CritterWatch is built on Fisher. No container, no connection to a network, backup is cp.

And just to be upfront about it, SQLite's big tradeoff comes along for the ride: one writer per database file, and one node per file. I'll come back to that at the end. If your system needs multiple nodes writing to the same database, you want Marten or Polecat instead.

From zero to persisted ​

Let's say we're building a little field service application that runs on a box at a depot somewhere. First, the one and only Nuget you need to get started:

bash
dotnet add package Fisher

There's nothing else to install. Fisher sits on top of Microsoft.Data.Sqlite, which ships the SQLite engine itself. Next, the bootstrapping:

csharp
builder.Services.AddFisher(opts =>
    {
        // That's the whole database "server"
        opts.Connection("Data Source=fieldservice.db");
    })
    // Build or migrate the SQLite schema at startup
    .ApplyAllDatabaseChangesOnStartup();

And now the part that I think matters the most. Here's our persisted model:

csharp
public enum WorkOrderStatus
{
    Open,
    Assigned,
    Completed
}

public record PartUsed(string Sku, int Quantity);

public class WorkOrder
{
    public Guid Id { get; set; }
    public string Customer { get; set; } = "";
    public string Description { get; set; } = "";
    public WorkOrderStatus Status { get; set; }
    public string? TechnicianId { get; set; }
    public DateTimeOffset Opened { get; set; }

    // No child table, no foreign key, no mapping.
    // It's just part of the document
    public List<PartUsed> Parts { get; set; } = [];
}

public class Technician
{
    // I'm using the technician's badge number as the identity
    public string Id { get; set; } = "";
    public string Name { get; set; } = "";
    public int OpenWorkOrders { get; set; }
}

That's it. There is no DbContext, no DbSet<T>, no OnModelCreating(), no base class, no marker interface, and no attributes. Fisher needs to be able to find an Id and it needs the type to be serializable by System.Text.Json. If you've used Marten or Polecat before, this is the exact same IDocumentStore / IDocumentSession / IQuerySession model you already know:

csharp
public static async Task UseFisher(IDocumentSession session, CancellationToken token)
{
    var order = new WorkOrder
    {
        Customer = "Acme",
        Description = "Fix the compressor",
        Opened = DateTimeOffset.UtcNow
    };

    // Fisher assigns the Guid identity for you here
    session.Store(order);

    // One unit of work, one SQLite transaction
    await session.SaveChangesAsync(token);

    // Load by id
    var loaded = await session.LoadAsync<WorkOrder>(order.Id, token);

    // Or query with LINQ, including down into the child collection
    var usingBelts = await session.Query<WorkOrder>()
        .Where(x => x.Status != WorkOrderStatus.Completed)
        .Where(x => x.Parts.Any(p => p.Sku == "BELT-9"))
        .OrderBy(x => x.Opened)
        .ToListAsync(token);
}

Underneath, there's one table per document type, and the document itself is a JSON string in a data column. This is the actual row from the sample application I built for this post:

json
{
  "id": "01a0f969-808b-71e5-be87-0a3ec122f9f8",
  "customer": "Acme",
  "description": "Fix the compressor",
  "status": 1,
  "technicianId": "T-100",
  "opened": "2026-10-01T21:40:36.107768+00:00",
  "parts": [{ "sku": "BELT-9", "quantity": 2 }]
}

The LINQ provider translates your query into SQL over SQLite's built in JSON functions. And if you write a LINQ query that Fisher can't translate, it throws an exception that names the operator instead of quietly pulling the whole table into memory to evaluate it on the client. I think that's the right call for a tool that's supposed to be boring in production.

I didn't register either document type anywhere in that configuration. Fisher happily builds the table for a document type the first time you read or write one.

Why I think this is more productive than EF Core ​

I want to be careful here. EF Core is a very good tool and I've spent a good part of the past couple years making Wolverine work better with EF Core, because that's what a lot of our users have. If your data is deeply relational, if you need to do a lot of ad hoc reporting across it, or if your team just knows EF Core cold, go in peace.

But I've also been saying for more than a decade that a document database approach lets you get things done with far less ceremony than an ORM over relational tables possibly can, and I think that argument gets stronger when the database is an embedded SQLite file. Here's why.

Your class is the model. To persist that WorkOrder with EF Core, you'd write the same class and then a DbContext with a couple DbSet<T> properties, and then you'd have to make a decision about Parts. Is that an owned collection in its own table with a foreign key back to the work order? A JSON column? Does PartUsed need a key of its own? None of those are hard questions, but you have to answer every one of them before you've written a line of code that does anything for your business, and you get to revisit those answers every time the shape changes. With Fisher the answer is always the same. It's part of the document.

Most changes to your model aren't schema changes at all. Say I need a new property on WorkOrder:

csharp
public string? SiteNotes { get; set; }

With Fisher, I'm done. I did exactly this to the sample application with existing data in the file, restarted it, and the old rows came back with "siteNotes": null because there's no column to add. With EF Core that same change is:

bash
dotnet ef migrations add AddSiteNotes
dotnet ef database update

plus a new migration class and an updated model snapshot to check into source control, and the merge conflicts in that snapshot when a teammate added a migration on their branch the same day. Multiply that by every little model change over the life of a project. I'm a big believer in evolutionary design, and anything that puts a tax on changing your mind about your domain model is a tax on the quality of that model.

The aggregate is one row. Loading a WorkOrder with its parts is a single read by primary key. There's no Include() to remember, no lazy loading surprise, no N+1 problem to go hunting for later, and no "split query or single query?" decision. You get the whole consistent document or you get nothing.

SQLite specifically is a rough place for EF Core migrations. This isn't a knock on the EF Core team, it's just what SQLite is. SQLite's ALTER TABLE is very narrow, so Microsoft's own documentation on the SQLite provider's limitations shows a long list of ordinary migration operations that can only be done by rebuilding the entire table: create a new one, copy the data, drop the old one, rename. The same page tells you that comparing or ordering on a DateTimeOffset or a decimal gets evaluated on the client, and recommends that you just not use DateTimeOffset. In a document model most of this never comes up, because most of what would have been ALTER TABLE is just a different shaped JSON document. And that OrderBy(x => x.Opened) up above on a DateTimeOffset? That runs in the database with Fisher.

It's in process. A lot of what makes ORM usage painful against a database server is chattiness, because every query is a network round trip. That cost simply isn't there with an embedded database. Fisher even goes a step further with its ASP.NET Core integration, which can stream the stored JSON straight to an HTTP response without ever deserializing it into your .NET type and serializing it right back out.

To be fair to EF Core in return: it has had JSON column mapping for a few releases now, Database.MigrateAsync() at startup does work, and relational tables are still the better fit for heavily relational data. The document approach also makes you think harder about renaming a property, since your old documents still have the old name (the patching API is your friend there). And it doesn't have to be either/or. Fisher has an EF Core integration that lets a DbContext write inside of Fisher's transaction if you really want a few flat tables next to your documents.

Migrations that "just work" ​

Okay, but some things really are schema changes. I want an index. I want soft deletes. This is where Weasel comes in, and it's honestly one of my favorite parts of the whole Critter Stack.

Weasel doesn't work off of a chain of hand authored migration files. Instead, it takes the configuration of your document store, compares that to the actual database in front of it, and applies whatever the difference is. So the first time our application starts up, fieldservice.db doesn't exist. SQLite creates the file, Weasel creates the tables, and we're in business. I never typed a command, and there's no InitialCreate migration anywhere in my codebase.

Now let's change our minds about a few things:

csharp
builder.Services.AddFisher(opts =>
    {
        opts.Connection("Data Source=fieldservice.db");

        opts.Schema.For<WorkOrder>()
            // I query on this a lot, so pull it out and index it
            .Duplicate(x => x.TechnicianId)
            .Index(x => x.Status);

        // We decided we never *really* delete technicians
        opts.Schema.For<Technician>().SoftDeleted();
    })
    .ApplyAllDatabaseChangesOnStartup();

I restart the application, and it just works. That's the whole workflow. If you'd like to see what Weasel is going to do before it does it, Fisher plugs into the same command line tooling as Marten and Polecat. This is the real output of dotnet run -- db-patch patch.sql against my existing database file after making that configuration change:

sql
BEGIN TRANSACTION;

ALTER TABLE fi_doc_workorder ADD COLUMN technician_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.technicianId')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_fi_doc_workorder_technician_id ON fi_doc_workorder (technician_id);
CREATE INDEX IF NOT EXISTS idx_fi_doc_workorder_status ON fi_doc_workorder (json_extract(data, '$.status'));
ALTER TABLE fi_doc_technician ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0;
ALTER TABLE fi_doc_technician ADD COLUMN deleted_at TEXT;

COMMIT;

A few things I want to call out in there:

  • That "duplicated" field is a virtual generated column computed from the JSON, so there's no backfill. Every existing row is correct the moment the column exists, because nothing ever writes to it.
  • dotnet run -- db-assert will tell you if the database matches the configuration and fail if it doesn't, db-patch writes the delta, and db-apply applies it. After restarting the application, db-assert came back with No database differences detected.
  • If you'd rather have the application refuse to start against a database it doesn't recognize than migrate it, swap ApplyAllDatabaseChangesOnStartup() for AssertDatabaseMatchesConfigurationOnStartup(), or set AutoCreate.None and own the scripts yourself.

I think this model matters more for an embedded database than it does for a server. If your application is installed on a few hundred desktops or edge devices, there is no DBA, there's no deployment pipeline running scripts, and those database files are at whatever version the user last bothered to upgrade to. "Look at the file, make it match what this version of the code needs" is exactly the behavior you want.

And in the spirit of honesty, Weasel can't make SQLite into something it isn't. SQLite can't add a constraint or change a column type on an existing table, so something like adding a foreign key to a document table that already exists means recreating that table. Weasel will report that to you instead of attempting it behind your back. The migrations documentation spells out what SQLite can and can't alter.

What else is in the box ​

Fisher inherited a decade's worth of document database features from Marten by way of the shared Weasel.Storage runtime, so this isn't a toy key/value store. The short list:

Fisher with Wolverine ​

Fisher has a first class integration with Wolverine, and I think this combination is the fastest path I know of in .NET to a small application with real persistence and reliable background processing with zero infrastructure. Add a couple more Nugets:

bash
dotnet add package WolverineFx.Fisher
dotnet add package WolverineFx.Http.Fisher

And here's the entire Program for our field service application:

csharp
var builder = WebApplication.CreateBuilder(args);
builder.Host.ApplyJasperFxExtensions();

builder.Services.AddFisher(opts =>
    {
        opts.Connection("Data Source=fieldservice.db");
    })
    .ApplyAllDatabaseChangesOnStartup()

    // Transactional middleware, the inbox & outbox, and saga storage
    // for Wolverine, all inside the same SQLite file
    .IntegrateWithWolverine();

builder.Host.UseWolverine(opts =>
{
    // One file, one node
    opts.Durability.Mode = DurabilityMode.Solo;

    opts.Policies.AutoApplyTransactions();

    // Messages in local queues are persisted in the SQLite file,
    // so background work survives a restart
    opts.Policies.UseDurableLocalQueues();
});

builder.Services.AddWolverineHttp();

var app = builder.Build();
app.MapWolverineEndpoints();

return await app.RunJasperFxCommands(args);

With that in place, we can use Wolverine's declarative persistence helpers to write HTTP endpoints that never touch an IDocumentSession at all. I wrote about why I think this matters in Wolverine is the Undisputed Champion of Low Ceremony Code, but the short version is that our endpoints become synchronous, pure functions that are trivial to unit test:

csharp
public record OpenWorkOrder(string Customer, string Description);
public record AddPart(string Sku, int Quantity);
public record WorkOrderCompleted(Guid WorkOrderId, string? TechnicianId);

public static class WorkOrderEndpoints
{
    [WolverinePost("/workorders")]
    public static (CreationResponse<Guid>, Insert<WorkOrder>) Post(OpenWorkOrder command)
    {
        var order = new WorkOrder
        {
            Id = Guid.CreateVersion7(),
            Customer = command.Customer,
            Description = command.Description,
            Opened = DateTimeOffset.UtcNow
        };

        // The first value is the HTTP response, the second tells
        // Wolverine to insert the new document
        return (
            new CreationResponse<Guid>($"/workorders/{order.Id}", order.Id),
            Storage.Insert(order));
    }

    // Wolverine loads the WorkOrder from Fisher using the "id" route
    // argument, and returns a 404 if it doesn't exist
    [WolverineGet("/workorders/{id}")]
    public static WorkOrder Get([Entity] WorkOrder order) => order;

    [WolverinePost("/workorders/{id}/parts")]
    public static Update<WorkOrder> AddPart(AddPart command, [Entity] WorkOrder order)
    {
        order.Parts.Add(new PartUsed(command.Sku, command.Quantity));
        return Storage.Update(order);
    }

    [WolverinePost("/workorders/{id}/complete")]
    public static (Update<WorkOrder>, WorkOrderCompleted) Complete([Entity] WorkOrder order)
    {
        order.Status = WorkOrderStatus.Completed;

        // Update the document *and* publish a cascading message,
        // in one transaction through the Wolverine outbox
        return (
            Storage.Update(order),
            new WorkOrderCompleted(order.Id, order.TechnicianId));
    }

    // Read every document of a small reference collection
    [WolverineGet("/technicians")]
    public static IReadOnlyList<Technician> Technicians(
        [All] IReadOnlyList<Technician> technicians) => technicians;
}

The very same helpers work in message handlers. Here, [Entity] finds the identity of each document from the message itself by looking for WorkOrderId and TechnicianId properties:

csharp
public record AssignTechnician(Guid WorkOrderId, string TechnicianId);
public record TechnicianAssigned(Guid WorkOrderId, string TechnicianId);

public static class AssignTechnicianHandler
{
    public static (Update<WorkOrder>, Update<Technician>, TechnicianAssigned) Handle(
        AssignTechnician command,
        [Entity] WorkOrder order,
        [Entity] Technician technician)
    {
        order.TechnicianId = technician.Id;
        order.Status = WorkOrderStatus.Assigned;
        technician.OpenWorkOrders++;

        // Two document updates and an outgoing message. Wolverine commits
        // all three together or not at all
        return (
            Storage.Update(order),
            Storage.Update(technician),
            new TechnicianAssigned(order.Id, technician.Id));
    }
}

public static class WorkOrderCompletedHandler
{
    public static IStorageAction<Technician> Handle(
        WorkOrderCompleted message,
        [Entity(Required = false)] Technician? technician)
    {
        // Nobody was ever assigned, so there's nothing to do
        if (technician is null) return Storage.Nothing<Technician>();

        technician.OpenWorkOrders--;
        return Storage.Update(technician);
    }
}

A couple notes on that code:

  • If the WorkOrder or Technician referenced by an AssignTechnician message doesn't exist, Wolverine logs that and stops before your handler is ever called. You can change that behavior with the OnMissing property on [Entity].
  • Nothing in those handlers refers to Fisher. [Entity], [All], and the Storage helpers live in Wolverine.Persistence, and that same code would compile and run against Marten, Polecat, or EF Core.
  • The Wolverine inbox and outbox tables live in the same SQLite file as your documents and commit on the same connection in the same transaction, which is the only way that could work with one writer per file.
  • [WriteAggregate] and the rest of the aggregate handler workflow for Event Sourcing work with Fisher too, but that's a different post.

I ran every one of these endpoints and handlers for real against Fisher 1.15 and Wolverine 6.44 while writing this post. If you want to see exactly what Wolverine does around your code, dotnet run -- wolverine-diagnostics codegen-preview --handler AssignTechnician will show you the generated code.

Know the limits ​

Fisher is a production store, but it's a single node production store, and you should go in with eyes open:

  • One writer per file. That's SQLite, and it's not a tuning knob. Fisher runs its writes through a real retry policy for when two writers collide, and a database file per tenant gives every tenant its own writer, but if you need many nodes writing to one database, this isn't your tool.
  • One node per file. Wolverine has to run in Solo mode, and the async daemon for projections can't do hot/cold failover across a cluster.
  • String searches are case sensitive, and a decimal inside a document is compared and summed as a double. The Fisher documentation on SQLite's behavior is very upfront about all of this.
  • System.Text.Json only. No Newtonsoft.
  • The Wolverine integration doesn't support multi-tenancy with Fisher yet.

One more that I feel pretty strongly about: don't use Fisher as a stand in for PostgreSQL or SQL Server in your integration tests just because the API is the same. Marten, Polecat, and Fisher share an API on purpose, and that does mean an application that outgrows a single file has a very reasonable path to PostgreSQL or SQL Server later. But the migration guide lists real behavioral differences between the three, and those differences are exactly the kind of thing your integration tests exist to catch. Test against the database you deploy on.

Wrapping up ​

I've said for years that the combination of a document database and "it just works" schema management is a huge productivity win for teams, and that it's a shame that most .NET developers never get to experience it because their shop's database was chosen for them long ago. An embedded database is one of the few places where that choice really is yours. Nobody in operations has to approve a file.

So the next time you catch yourself reaching for EF Core and SQLite for a desktop tool, a kiosk, an edge service, or that little internal application that doesn't deserve its own database server, give Fisher a look first. I think you'll write a lot less code, and I think you'll change your mind about your domain model a lot more freely, which is the whole point.

The getting started guide will have you persisting documents in a couple minutes, the code is on GitHub, and as always, come find us in the Critter Stack Discord with questions.

RSS Feed · All Rights Reserved.