Skip to content

Database

Burrow uses Den, an object-document mapper (ODM) for Go. Den supports two storage backends — choose the one that fits your deployment:

SQLite (Default)

SQLite fits the "download, start, use" philosophy. No database server to install or maintain. Your application compiles to a single binary, and the data lives in one file next to it. Ideal for self-hosted apps, internal tools, and prototyping.

  • No CGO required — pure Go via modernc.org/sqlite, cross-compiles anywhere
  • Zero dependencies — no libsqlite3-dev, no shared libraries
  • Production-ready — WAL mode, connection pooling, and tuned PRAGMAs out of the box

PostgreSQL

When you need replication, concurrent writers, or a managed database service, switch to PostgreSQL — no code changes required. Same Den API, same document types, different backend.

  • Full JSONB support — GIN indexes for fast queries
  • Multi-writer concurrency — no single-writer bottleneck
  • Managed hosting — works with any PostgreSQL provider

How It Works

At startup, Burrow opens the database using the DSN (Data Source Name — a URL-style connection string, e.g., sqlite:///app.db or postgres://user:pass@host/db) and configures it with production-ready defaults inspired by dj-lite. For SQLite, per-connection PRAGMAs are set via _pragma DSN parameters so they apply to every connection in the pool:

PRAGMA Value Purpose
journal_mode WAL Write-Ahead Logging for concurrent reads during writes
synchronous NORMAL Balance between durability and write performance
foreign_keys ON Enforce referential integrity
busy_timeout 5000 (5s) Wait up to 5 seconds for a lock instead of failing immediately
temp_store MEMORY Store temporary tables in RAM for faster queries
mmap_size 134217728 (128MB) Memory-mapped I/O for improved read performance
journal_size_limit 27103364 (~26MB) Prevent WAL journal files from growing indefinitely
cache_size 2000 Number of database pages held in memory

Additionally, Burrow sets the transaction mode to IMMEDIATE. This ensures that write transactions acquire a lock immediately and wait up to busy_timeout instead of failing with a "database is locked" error.

These settings are fixed and cannot be overridden. They are tuned for the typical Burrow use case — self-hosted applications with moderate concurrency — and should work well without any tuning.

The connection pool is configured with:

  • Max 10 open connections
  • Max 5 idle connections
  • 1 hour connection lifetime

Configuration

The database path is configured via the --database-dsn flag:

./myapp --database-dsn sqlite:///data/myapp.db
DATABASE_DSN=sqlite:///data/myapp.db ./myapp
[database]
dsn = "sqlite:///data/myapp.db"

The default is sqlite:///data/app.db, colocated with the attachment storage at ./data/media. Den's SQLite backend auto-creates the parent directory, so a fresh checkout works without a manual mkdir. For PostgreSQL, use a URL like postgres://user:pass@localhost/mydb.

For testing, you can use an in-memory SQLite database:

./myapp --database-dsn sqlite:///:memory:

Working with Den

Burrow uses Den, an object-document mapper (ODM) for Go. Apps receive a *den.DB instance via AppConfig during registration. Den stores documents as JSON internally and uses ULID-based IDs via document.Base.

Defining Documents

Documents are Go structs that embed document.Base for ID and timestamp management:

type Note struct {
    document.Base
    UserID    string    `json:"user_id"`
    Title     string    `json:"title" den:"index"`
    Content   string    `json:"content"`
    CreatedAt time.Time `json:"created_at"`
}

Common struct tags:

Tag Purpose
json:"name" JSON field name for serialization
den:"index" Add a secondary index on this field
den:"unique" Unique constraint on this field
den:"fts" Full-text search index on this field
den:"omitempty" Omit from JSON when empty
validate:"required" Enforce at insert/update time (see below)

Struct-Tag Validation

Burrow enables Den's struct-tag validation by default. Any document field marked with validate:"..." is checked before every Insert and Update; a violation returns an error wrapping den.ErrValidation.

type Note struct {
    document.Base
    UserID  string `json:"user_id"  validate:"required"`
    Title   string `json:"title"    den:"index" validate:"required,min=1,max=200"`
    Content string `json:"content"`
}

Supported tags come from go-playground/validator: required, email, url, min, max, oneof=..., gte, lte, and many more. See the Den validation guide for the full execution order — mutating hooks (BeforeInsert, BeforeSave) run before validation so defaults can be populated first.

Form-only validators belong on form DTOs

Some validators like eqfield (matching a field on a sibling struct) are meant for form input and don't make sense on a persisted document. Keep those on separate form DTOs and use burrow.Bind() to validate request input before copying values into the document.

Queries

Den provides a chainable QuerySet API for queries and a functional API for mutations:

// Find by ID
note, err := den.FindByID[Note](ctx, db, id)

// Find one with conditions
note, err := den.NewQuery[Note](db, where.Field("user_id").Eq(userID)).First(ctx)

// Find many with sorting
notes, err := den.NewQuery[Note](db,
    where.Field("user_id").Eq(userID),
).Sort("created_at", den.Desc).All(ctx)

// Save — inserts when ID is empty, updates when populated
note := &Note{UserID: "01J...", Title: "Hello"}
err := den.Save(ctx, db, note)

// Delete
err := den.Delete(ctx, db, note)

// Count
count, err := den.NewQuery[Note](db, where.Field("user_id").Eq(userID)).Count(ctx)

// Exists check
exists, err := den.NewQuery[Note](db, where.Field("id").Eq(id)).Exists(ctx)

Transactions

Use den.RunInTransaction() for atomic operations:

err := den.RunInTransaction(ctx, db, func(tx *den.Tx) error {
    if err := den.Save(ctx, tx, note); err != nil {
        return err
    }
    if err := den.Save(ctx, tx, tag); err != nil {
        return err
    }
    return nil
})

Repository Pattern

Burrow's contrib apps use a repository pattern to encapsulate database access. This keeps handlers clean and makes testing easier.

// Repository wraps database access for an app.
type Repository struct {
    db *den.DB
}

func NewRepository(db *den.DB) *Repository {
    return &Repository{db: db}
}

func (r *Repository) GetNoteByID(ctx context.Context, id string) (*Note, error) {
    note, err := den.FindByID[Note](ctx, r.db, id)
    if err != nil {
        return nil, fmt.Errorf("get note %s: %w", id, err)
    }
    return note, nil
}

func (r *Repository) CreateNote(ctx context.Context, note *Note) error {
    if err := den.Save(ctx, r.db, note); err != nil {
        return fmt.Errorf("create note: %w", err)
    }
    return nil
}

Wire it up in your app's Configure() method:

func (a *App) Configure(cfg *burrow.AppConfig, _ *cli.Command) error {
    a.repo = NewRepository(cfg.DB)
    return nil
}

Handlers then use the repository through the app:

func (a *App) handleGetNote(w http.ResponseWriter, r *http.Request) error {
    id := chi.URLParam(r, "id")
    note, err := a.repo.GetNoteByID(r.Context(), id)
    if err != nil {
        return burrow.NewHTTPError(http.StatusNotFound, "Note not found")
    }
    return burrow.JSON(w, http.StatusOK, note)
}

Document Schema

Den automatically manages collections (tables) based on your document structs. When you register documents with your app, Den creates and updates the underlying schema — no manual SQL migrations needed for document structure.

See the Migrations guide for details on how schema management works with Den.

Further Reading