Database-backed, typed application settings for SaaS applications. Declare a
setting once as a Key[T] with a default and a validation rule, then let
operators override it globally, per tenant or per user, with the most specific
value winning. Values are checked on the way in and on the way out, secrets
are encrypted at rest with rotating AES-256-GCM keys, reads are served from an
in-process cache that PostgreSQL LISTEN/NOTIFY keeps coherent across
instances, and every change can feed your audit log.
go get github.com/byteminus/go-settings
Requires Go 1.26 or later. CI runs the PostgreSQL store against PostgreSQL 16, 17 and 18.
package main
import (
"context"
"errors"
"log"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/pgxstore"
)
var TrialDays = settings.Key[int]{
Name: "billing.trial_days",
Default: 14,
Description: "Length of the free trial in days",
Validate: func(v int) error {
if v < 1 || v > 90 {
return errors.New("must be between 1 and 90")
}
return nil
},
}
func main() {
ctx := context.Background()
url := os.Getenv("DATABASE_URL")
pool, err := pgxpool.New(ctx, url)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
store, err := pgxstore.New(pool)
if err != nil {
log.Fatal(err)
}
if err := store.CreateSchema(ctx); err != nil {
log.Fatal(err)
}
// The listener keeps this process's cache coherent with writes made by
// other instances.
listener, err := pgxstore.NewListener(pgxstore.ListenerConfig{
Connect: func(ctx context.Context) (*pgx.Conn, error) { return pgx.Connect(ctx, url) },
OnError: func(err error) { log.Printf("settings listener: %v", err) },
})
if err != nil {
log.Fatal(err)
}
go func() { _ = listener.Run(ctx) }()
reg, err := settings.NewRegistry(TrialDays)
if err != nil {
log.Fatal(err)
}
client, err := settings.New(store, reg, settings.Options{Invalidations: listener})
if err != nil {
log.Fatal(err)
}
defer client.Close()
acme := settings.Tenant("acme")
admin := settings.WithActor(ctx, settings.Ref{Type: "user", ID: "admin@example.com"})
if err := settings.Set(admin, client, TrialDays, acme, 30); err != nil {
log.Fatal(err)
}
days, err := settings.Get(ctx, client, TrialDays, settings.User("u-42"), acme)
if err != nil {
log.Fatal(err)
}
log.Printf("trial: %d days", days) // 30, from the tenant override
}Keys. A Key[T] has a dotted Name (segments matching [a-z][a-z0-9_]*,
such as billing.trial_days), a Default, a Description, an optional
Validate func(T) error and a Secret flag. Keys are registered in a
Registry, which rejects malformed and duplicate names and checks that the
default encodes to JSON, decodes back and passes Validate. Registration is
all or nothing. Every operation on an unregistered name fails with
ErrUnknownKey; using a key name with a different T fails with
ErrTypeMismatch.
Values. Values are stored as JSON and decoded strictly into T: unknown
object fields and trailing data are errors. Validate runs on every write and
on every read, so a stored value that no longer satisfies the rules surfaces as
ErrInvalidValue instead of reaching your code. A value's JSON encoding may
be at most MaxValueLen (64 KiB).
Scopes and resolution. A Scope is a type and an ID, both always set:
settings.Global() (which is {Type: "global", ID: "global"}),
settings.Tenant(id), settings.User(id) or any custom type such as
{Type: "workspace", ID: id}. Options.Order lists scope types from highest
to lowest precedence; the default is user, tenant, global. Get(ctx, client, key, scopes...) consults the given scopes in that order (argument order does
not matter), then the global scope (added automatically), then the default.
Resolve returns the same value plus the scope it came from, its
UpdatedAt and UpdatedBy, for admin screens that show where an effective
value is set. Put the global scope first in Order to make operator values
override tenant choices.
Writes. Set writes a typed value at one scope; SetJSON writes by name
from JSON for admin forms; Unset deletes an override so resolution falls
through. All(ctx, scope) lists every registered key with its metadata and
the override stored at exactly that scope.
Secrets. Values of Secret keys are encrypted before they reach the store
through an Encrypter. The built-in AESGCM uses AES-256-GCM with a fresh
random 96-bit nonce per value and embeds the key ID in each ciphertext:
construct it with the current key ID and every key still in use, and old
values keep decrypting while new ones use the current key. Each ciphertext is
bound to its key name, scope and key ID through GCM's additional data, so a
value copied to another row, scope or key fails to decrypt.
The package redacts secret values wherever it controls their output: All
returns the "[redacted]" marker for secret values and defaults unless
called with settings.RevealSecrets(), the OnChange hook and Change
(printed or JSON-encoded) always carry the marker, error messages never
quote a secret value, and fmt, slog and encoding/json print the marker
for the default of a secret Key and the value of a secret Resolved.
Only Get, Resolve (its Value field) and All with RevealSecrets
return plaintext. What the package cannot reach: fmt prints an unexported
struct field by reflection without calling its methods, so a secret
Resolved or Key stored in an unexported field of your own struct shows
in %v of that struct; keep only the values you need, where you need them.
To rotate keys, give every process a keyring whose current key is the new one
and that still holds the old ones, then call client.Reencrypt(ctx) once.
It scans the whole table, including keys only other services register,
rewrites every encrypted value with the current key without overwriting
concurrent writes, and keeps who last updated each value. Remove the old
keys only when the result's Complete() reports true; otherwise
Leftovers lists the values that still need them. This is how you retire a
compromised key.
func rotate(ctx context.Context, store settings.Store, reg *settings.Registry, oldKey, newKey []byte) error {
enc, err := settings.NewAESGCM("2026-11", map[string][]byte{
"2026-10": oldKey, // still needed to read values written before the rotation
"2026-11": newKey, // encrypts everything written from now on
})
if err != nil {
return err
}
client, err := settings.New(store, reg, settings.Options{Encrypter: enc})
if err != nil {
return err
}
defer client.Close()
res, err := client.Reencrypt(ctx)
if err != nil {
// Not complete: res.Leftovers says which values still need 2026-10.
return err
}
log.Printf("re-encrypted %d secrets; key 2026-10 can now be removed", res.Rewritten)
return nil
}Security model. Encryption protects secret values from anyone who can
read the settings table, its backups or its replicas without holding the
keys. It does not protect against someone who can write to the table: they
cannot forge or move a ciphertext to another row, scope or key, but they can
put back an older ciphertext of the same row (from a backup, for example),
and it decrypts to that row's older value for as long as the key that sealed
it is in the keyring. Treat write access to the table as trusted, protect
backups like the live table, and after a rotation remove retired keys as
soon as Reencrypt reports completion, which makes every ciphertext sealed
under them unreadable.
Cache. Reads go through an in-process cache (default TTL 30 seconds,
10,000 entries) that also remembers which scopes have no override, so the
common "no user override" lookup costs nothing. Writes through a client
invalidate its own cache immediately. A write made through another client is
seen no later than CacheTTL after the cached lookup started, and as soon as
its notification arrives when Options.Invalidations is set to a
pgxstore.Listener. The listener reconnects with jittered backoff after
connection loss, tells the cache to drop everything whenever notifications
may have been missed, and reports every failure to ListenerConfig.OnError;
while it is disconnected the CacheTTL bound applies. A negative CacheTTL
disables the cache.
Auditing. Options.OnChange is called after every write that changed the
store with a Change: key, scope, old and new JSON (nil when absent), the
actor, and the time. The actor is a settings.Ref (a type and an ID, such as
{Type: "user", ID: "42"} or {Type: "job", ID: "nightly-sync"}), stored with
the value as UpdatedBy. If the hook returns an error, Set returns it; the
write has already happened, so if you need the audit record and the change to
be atomic, run both in one transaction (below).
Actor wiring. By default the actor is whatever settings.WithActor put in
the context. An application that already keeps its user in the context can
supply the lookup once instead, through Options.ActorFrom, which has the
same signature as settings.ActorFrom:
type userKey struct{}
type User struct{ ID string }
func newClient(store settings.Store, reg *settings.Registry) (*settings.Client, error) {
return settings.New(store, reg, settings.Options{
ActorFrom: func(ctx context.Context) (settings.Ref, bool) {
u, ok := ctx.Value(userKey{}).(User)
return settings.Ref{Type: "user", ID: u.ID}, ok
},
})
}pgxstore.New(db, opts...) accepts anything with pgx's Exec, Query and
QueryRow (*pgxpool.Pool, *pgx.Conn, pgx.Tx). Options:
WithTable("app.settings"): table name, optionally schema-qualified.WithChannel("settings_changed"): notification channel;""disables notifications.WithConn(fn): resolve the connection per call (see below).
CreateSchema(ctx) runs this idempotent DDL (Schema() returns it for
migration tools):
CREATE TABLE IF NOT EXISTS "settings" (
key text NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$' AND length(key) <= 200),
scope_type text NOT NULL CHECK (scope_type ~ '^[a-z][a-z0-9_]*$' AND length(scope_type) <= 64),
scope_id text NOT NULL CHECK (scope_id <> '' AND length(scope_id) <= 255),
value jsonb NOT NULL,
updated_at timestamptz NOT NULL,
updated_by_type text NOT NULL DEFAULT '' CHECK (length(updated_by_type) <= 64),
updated_by_id text NOT NULL DEFAULT '' CHECK (length(updated_by_id) <= 255),
PRIMARY KEY (key, scope_type, scope_id),
CHECK (scope_type <> 'global' OR scope_id = 'global'),
CHECK ((updated_by_type = '') = (updated_by_id = ''))
);
CREATE INDEX IF NOT EXISTS "settings_scope_idx" ON "settings" (scope_type, scope_id);Upgrading a 0.1.0 table: see the SQL in CHANGELOG.md.
Put is an upsert that is safe under concurrency and returns the value it
replaced (for the OnChange hook): a locking UPDATE that returns the old
row, falling back to INSERT ... ON CONFLICT DO NOTHING, retried if another
writer wins the race in between. Each write publishes its notification in the
same statement, so notifications are sent only for writes that commit.
Swap, used by Reencrypt, is a single conditional UPDATE: it re-checks
the expected value after waiting for any concurrent writer, so it never
overwrites a value it did not see.
WithConn lets settings writes join a transaction your code carries in its
context, so a setting change, your own audit row and anything else commit or
roll back together:
type txKey struct{}
func newStore(pool *pgxpool.Pool) (*pgxstore.Store, error) {
return pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
return tx
}
return nil // use the pool
}))
}
func extendTrial(ctx context.Context, pool *pgxpool.Pool, client *settings.Client, trialDays settings.Key[int]) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }() // no-op after Commit
txCtx := context.WithValue(ctx, txKey{}, tx)
// OnChange receives txCtx too and can insert the audit row with the same tx.
if err := settings.Set(txCtx, client, trialDays, settings.Tenant("acme"), 30); err != nil {
return err
}
return tx.Commit(ctx)
}Notifications are sent only on commit, so other instances never see a
rolled-back write. Reads made with a context for which WithConn returns a
connection run inside your transaction and bypass the client's cache in both
directions (pgxstore.Store implements settings.CallerConnReporter), so a
value read there, uncommitted or from an older REPEATABLE READ snapshot, is
never served to other requests. The writing client invalidates its own cache
when Set returns; if another request reads the key before the commit, it
caches the old committed value until the commit notification arrives (when
the client runs a Listener) or the TTL expires.
| Identifier | Purpose |
|---|---|
Key[T], AnyKey |
Typed setting declaration |
Registry, NewRegistry, Register, MustRegister, Lookup, Keys, KeyInfo |
Known keys and their metadata |
Scope, Global, Tenant, User, ValidateKeyName |
Scopes and name rules |
New, Options, Client, Close, DefaultOrder, DefaultCacheTTL, DefaultCacheSize |
Client construction |
Get, Resolve, Resolved[T] |
Typed reads |
Set, Client.SetJSON, Client.Unset, MaxValueLen |
Writes |
Client.All, Entry, RevealSecrets |
Admin listing |
Encrypter, KeyIdentifier, AESGCM, NewAESGCM |
Encryption at rest |
Client.Reencrypt, ReencryptResult, Leftover, MaxLeftovers |
Key rotation |
Change, Ref, WithActor, ActorFrom, Options.ActorFrom, Redacted |
Auditing |
Store, Record, CallerConnReporter, Invalidation, InvalidationSource |
Storage and cache extension points |
memstore.New |
In-memory store for tests |
pgxstore.New, WithTable, WithChannel, WithConn, Schema, CreateSchema |
PostgreSQL store |
pgxstore.NewListener, ListenerConfig, Listener.Run, Listener.Subscribe |
Cross-instance invalidation |
storetest.Run |
Store conformance suite |
Errors are checkable with errors.Is. Every package error wraps one of the
family sentinels ErrNotFound (ErrUnknownKey), ErrConflict
(ErrDuplicateKey, pgxstore.ErrContention) or ErrInvalid
(ErrInvalidKey, ErrTypeMismatch, ErrInvalidScope, ErrInvalidValue,
ErrInvalidRecord, ErrNoEncrypter and configuration errors), so an
application can map them to HTTP statuses once. ErrDecrypt (stored data
that cannot be decrypted), ErrReencryptIncomplete and
pgxstore.ErrListenerRunning wrap none.
Use memstore.New() in unit tests; it enforces the same constraints as
PostgreSQL. To verify your own Store, run the conformance suite:
func TestMyStore(t *testing.T) {
storetest.Run(t, func(t *testing.T) settings.Store { return newMyStore(t) })
}This repository's own tests run with go test -race ./...; set
TEST_DATABASE_URL to include the PostgreSQL integration tests (see
CONTRIBUTING.md).
- Change history table and point-in-time reads.
- Per-key cache TTLs and bulk reads of many keys in one query.
- An embeddable admin HTTP handler built on
All,SetJSONandUnset. - Scope resolution from the request context (tenant and user middleware).
This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.
MIT; see LICENSE.