Skip to content
gohacks.dev
Databases

Embedded Key Value Stores in Go Bolt Badger and Pebble

Compare bbolt, Badger and Pebble, the embedded key value stores for Go: 100,000 keys loaded in 1.4s, 173ms and 256ms, plus reads, scans and size

11 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Not every program needs a database server. A CLI that remembers state, a service with a local index, a node in a cluster storing its own log, a cache that survives restarts: all want a place to put bytes by key, on local disk, without running anything. Go has three well known embedded key value stores that need no cgo and no separate process: bbolt, Badger and Pebble. They look similar from outside and are built on different ideas, which decide what each is good at. This roundup measures all three on the same load and gives a verdict by use case.

Tested with Go 1.25.6, bbolt v1.4.3, Badger v4.8.0, Pebble v2.1.7, macOS on Apple Silicon (arm64)

It belongs to the database cornerstone. Each store has its own article: bbolt in practice and Badger. For SQL in the same embedded role, see SQLite in Go.

What they are

bboltBadgerPebble
StructureB+tree in one memory mapped fileLSM tree with a separate value logLSM tree
WritersOne at a time, readers never blockConcurrent transactions with conflict detectionBatches, concurrent
TransactionsSerialisable, ACIDSnapshot isolation with ErrConflictBatches and snapshots, no multi key transactions
Durability defaultfsync on every commitWrites buffered, sync optionalSync option per write
TTL on keysNoYes, per entryNo
OriginA port of LMDB by Ben Johnson, now in etcdDgraphCockroachDB, after LevelDB and RocksDB

An LSM tree (log structured merge tree) turns random writes into sequential ones: new data goes to a memory table and a log, and is merged into sorted files in the background. Writes are cheap; reads may check several files; background compaction costs CPU and disk. A B+tree updates pages in place: reads are direct, writes touch pages and must be made durable, so they cost more.

The measurement

main.goGo
package main

import (
	"encoding/binary"
	"fmt"
	"log"
	"math/rand/v2"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/cockroachdb/pebble/v2"
	"github.com/cockroachdb/pebble/v2/bloom"
	"github.com/dgraph-io/badger/v4"
	bolt "go.etcd.io/bbolt"
)

const n = 100000

func key(i int) []byte {
	k := make([]byte, 8)
	binary.BigEndian.PutUint64(k, uint64(i))
	return k
}

// val returns 100 pseudo random bytes that differ for every key, so compression cannot flatter a store.
func val(i int) []byte {
	v := make([]byte, 100)
	r := rand.New(rand.NewPCG(uint64(i), 99))
	for j := range v {
		v[j] = byte(r.IntN(256))
	}
	return v
}

type store interface {
	Load(n int) error
	Get(i int) error
	Scan() (int, error)
	Close() error
}

// bbolt: one B+tree file, a single writer, fsync on every commit.
type boltStore struct{ db *bolt.DB }

func (s boltStore) Load(n int) error {
	for lo := 0; lo < n; lo += 1000 {
		if err := s.db.Update(func(tx *bolt.Tx) error {
			b, err := tx.CreateBucketIfNotExists([]byte("kv"))
			if err != nil {
				return err
			}
			for i := lo; i < lo+1000 && i < n; i++ {
				if err := b.Put(key(i), val(i)); err != nil {
					return err
				}
			}
			return nil
		}); err != nil {
			return err
		}
	}
	return nil
}

func (s boltStore) Get(i int) error {
	return s.db.View(func(tx *bolt.Tx) error {
		if tx.Bucket([]byte("kv")).Get(key(i)) == nil {
			return fmt.Errorf("missing %d", i)
		}
		return nil
	})
}

func (s boltStore) Scan() (c int, err error) {
	err = s.db.View(func(tx *bolt.Tx) error {
		cur := tx.Bucket([]byte("kv")).Cursor()
		for k, _ := cur.First(); k != nil; k, _ = cur.Next() {
			c++
		}
		return nil
	})
	return
}
func (s boltStore) Close() error { return s.db.Close() }

// Badger: an LSM tree with a value log; writes are buffered and the default does not fsync each one.
type badgerStore struct{ db *badger.DB }

func (s badgerStore) Load(n int) error {
	wb := s.db.NewWriteBatch()
	defer wb.Cancel()
	for i := 0; i < n; i++ {
		if err := wb.Set(key(i), val(i)); err != nil {
			return err
		}
	}
	return wb.Flush()
}

func (s badgerStore) Get(i int) error {
	return s.db.View(func(tx *badger.Txn) error {
		_, err := tx.Get(key(i))
		return err
	})
}

func (s badgerStore) Scan() (c int, err error) {
	err = s.db.View(func(tx *badger.Txn) error {
		opts := badger.DefaultIteratorOptions
		opts.PrefetchValues = false
		it := tx.NewIterator(opts)
		defer it.Close()
		for it.Rewind(); it.Valid(); it.Next() {
			c++
		}
		return nil
	})
	return
}
func (s badgerStore) Close() error { return s.db.Close() }

// Pebble: the LSM tree used by CockroachDB; batches committed without an fsync here, as with Badger's default.
type pebbleStore struct{ db *pebble.DB }

func (s pebbleStore) Load(n int) error {
	for lo := 0; lo < n; lo += 1000 {
		b := s.db.NewBatch()
		for i := lo; i < lo+1000 && i < n; i++ {
			if err := b.Set(key(i), val(i), nil); err != nil {
				return err
			}
		}
		if err := b.Commit(pebble.NoSync); err != nil {
			return err
		}
	}
	return s.db.Flush()
}

func (s pebbleStore) Get(i int) error {
	v, closer, err := s.db.Get(key(i))
	if err != nil {
		return err
	}
	_ = v
	return closer.Close()
}

func (s pebbleStore) Scan() (c int, err error) {
	it, err := s.db.NewIter(nil)
	if err != nil {
		return 0, err
	}
	defer it.Close()
	for it.First(); it.Valid(); it.Next() {
		c++
	}
	return c, it.Error()
}
func (s pebbleStore) Close() error { return s.db.Close() }

type quiet struct{}

func (quiet) Infof(string, ...any)              {}
func (quiet) Errorf(string, ...any)             {}
func (quiet) Fatalf(format string, args ...any) { log.Fatalf(format, args...) }

func dirSize(dir string) int64 {
	var total int64
	_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
		if err == nil && !info.IsDir() {
			total += info.Size()
		}
		return nil
	})
	return total
}

func main() {
	root, err := os.MkdirTemp("", "kv640")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(root)

	open := map[string]func(dir string) store{
		"bbolt": func(dir string) store {
			db, err := bolt.Open(filepath.Join(dir, "kv.db"), 0o600, nil)
			if err != nil {
				log.Fatal(err)
			}
			return boltStore{db}
		},
		"badger": func(dir string) store {
			db, err := badger.Open(badger.DefaultOptions(dir).WithLoggingLevel(badger.ERROR))
			if err != nil {
				log.Fatal(err)
			}
			return badgerStore{db}
		},
		"pebble": func(dir string) store {
			db, err := pebble.Open(dir, &pebble.Options{Logger: quiet{}})
			if err != nil {
				log.Fatal(err)
			}
			return pebbleStore{db}
		},
		"pebble+bloom": func(dir string) store {
			opts := &pebble.Options{Logger: quiet{}}
			for i := range opts.Levels {
				opts.Levels[i].FilterPolicy = bloom.FilterPolicy(10)
			}
			db, err := pebble.Open(dir, opts)
			if err != nil {
				log.Fatal(err)
			}
			return pebbleStore{db}
		},
	}
	fmt.Printf("%d keys of 8 bytes with 100 byte values, then %d random reads and one full scan\n", n, n)
	fmt.Printf("%-13s %10s %10s %10s %10s\n", "store", "load", "reads", "scan", "on disk")
	for _, name := range []string{"bbolt", "badger", "pebble", "pebble+bloom"} {
		dir := filepath.Join(root, strings.ReplaceAll(name, "+", "-"))
		_ = os.MkdirAll(dir, 0o755)
		s := open[name](dir)

		start := time.Now()
		if err := s.Load(n); err != nil {
			log.Fatal(name, err)
		}
		load := time.Since(start)

		r := rand.New(rand.NewPCG(1, 2))
		start = time.Now()
		for i := 0; i < n; i++ {
			if err := s.Get(r.IntN(n)); err != nil {
				log.Fatal(name, err)
			}
		}
		reads := time.Since(start)

		start = time.Now()
		c, err := s.Scan()
		if err != nil || c != n {
			log.Fatal(name, c, err)
		}
		scan := time.Since(start)
		_ = s.Close()
		fmt.Printf("%-13s %10v %10v %10v %8.1f MB\n", name, load.Round(time.Millisecond), reads.Round(time.Millisecond), scan.Round(time.Millisecond), float64(dirSize(dir))/1e6)
	}
}
Text
100000 keys of 8 bytes with 100 byte values, then 100000 random reads and one full scan
store               load      reads       scan    on disk
bbolt             1.357s       96ms        1ms     33.6 MB
badger             173ms      167ms        9ms     13.3 MB
pebble             256ms      709ms       10ms     18.0 MB
pebble+bloom       250ms     1.197s        9ms     20.9 MB

The program loads 100,000 keys of 8 bytes with 100 byte values, each value different pseudo random data so that compression cannot flatter any store, then does 100,000 random point reads and one full ordered scan. It prints wall time and the size of the store on disk afterwards. The settings are the defaults of each library, except where the table notes them: bbolt commits 1,000 keys per transaction with an fsync each; Badger uses a write batch and its default of not syncing every write; Pebble commits batches of 1,000 with NoSync and flushes at the end. That makes load times a comparison of durability policy as much as of design, which is the honest way to read them.

StoreLoad100,000 random readsFull scanOn disk
bbolt1.357s96ms1ms33.6 MB
Badger173ms167ms9ms13.3 MB
Pebble256ms709ms10ms18.0 MB
Pebble with Bloom filters250ms1.197s9ms20.9 MB

The raw data is 10.8 MB (100,000 times 108 bytes), so the on disk figure is also the overhead of each design. Read the numbers in four ways.

Writes favour the LSM stores. Badger and Pebble loaded 5 to 8 times faster than bbolt, which spent most of the time on 100 fsyncs. If bbolt were given NoSync it would be much faster and would lose durability. Compare like with like before you choose: a store configured to lose the last second of writes on a crash is not doing the same job.

Reads favour the B+tree for a memory resident data set. bbolt answered 100,000 random reads in 96ms, about a microsecond each, since the whole 33 MB file is memory mapped and a lookup is a tree walk over cached pages. Badger took 167ms. Pebble took 709ms. The LSM stores may consult several files, and Pebble's default block cache and read path are tuned for data much larger than this test. At sizes beyond memory, LSM reads depend more on caching and filters, and the ranking can change. Do not take 100,000 keys as a model of 100 million.

Bloom filters are for misses. Pebble with a Bloom filter policy read slower, not faster. Every key in the test existed, so the filter was consulted and never saved a file read. Filters pay off on workloads with many lookups of absent keys. The lab shows that a tuning knob needs the right workload.

Space. bbolt's file was 33.6 MB for 10.8 MB of data, because pages are filled partially, and it never shrinks on its own (see the compaction step in the bbolt article). Badger was most compact in this run. Pebble's figure includes its write ahead log and manifest files.

Things the numbers hide

A benchmark of a hundred thousand keys on a laptop is a sketch. Four things it cannot show matter when you pick.

Crash behaviour. The test measures speed, and the reason to use a database is what happens when the power fails. bbolt's commit is durable when it returns. Badger and Pebble, as configured here, are not: a crash can lose the writes of the last moments, and both offer options to sync (SyncWrites in Badger, pebble.Sync on commit), which brings their load times much closer to bbolt's. Decide your durability requirement first, set each store to it, and only then compare speed.

Size over time. A store that is fast at 100 MB can behave differently at 100 GB. LSM stores need compaction, which uses disk bandwidth and can cause latency spikes under sustained writes; bbolt's file grows with data and reuses freed pages, but does not shrink. Test with data larger than memory and a workload shaped like yours, including deletes and overwrites.

Concurrency. The lab ran one goroutine. Under many writers, bbolt serialises them and the LSM stores overlap them, and under many readers all three scale well. The bbolt article shows how Batch changes the picture for small concurrent writes.

Operational surface. bbolt is one file you can copy while a read transaction is open (tx.WriteTo), which makes backup simple. Badger and Pebble are directories with several files and a defined procedure for consistent backup. All three need you to handle the file lock, disk full errors and a clean Close on shutdown, because a store that is not closed properly recovers from its log on the next start and takes longer to open.

Verdicts

bbolt: small to medium data, many reads, strict durability, simple needs. Config stores, indexes, metadata, state for tools. Its model is the easiest to reason about, since transactions are serialisable and a read never blocks. Its limits: a single writer, so write heavy workloads queue (the lab's 200 concurrent small writes took 1.87s with Update and 20ms with Batch), file growth that needs compaction, and a long read transaction that can stall a writer that needs to grow the file. It is archived in its original repository and maintained by the etcd team as go.etcd.io/bbolt; use that path.

Badger: write heavy, large values, TTLs, and many concurrent writers. It has transactions with optimistic conflict detection, TTLs, key only iteration and a design that keeps large values out of the LSM tree so compaction stays cheap. The costs are operational: a value log that needs garbage collection, several background goroutines, memory settings to tune and a transaction model where concurrent writers to the same key get ErrConflict and must retry (1,024 retries for 200 increments in the lab's contention test).

Pebble: when you are building a database or need to tune an LSM tree. It is the storage engine of CockroachDB, written in Go to replace RocksDB, with fine control over levels, filters, compression, block caches and snapshots, and iterators with bounds. It gives you less out of the box: no multi key transactions, no TTL and a tuning surface that rewards study. Pick it when you can use that control, and when you want a well supported LSM implementation that is not cgo.

What none of them is. They are key value stores, so there is no query language, no secondary indexes and no schema. Anything beyond get, put, delete and ordered range scan is code you write: key encoding, index maintenance, serialisation. Encode keys so that byte order is the order you want, with big endian integers as the lab does, and fixed prefixes for each kind of record. If you need a relational model in an embedded database, SQLite, pure Go through modernc.org/sqlite or with cgo, as in using SQLite in Go, is usually the better tool and saves you writing a query engine.

Choosing quickly

If you needPick
The simplest correct thing for a modest amount of databbolt
Heavy writes, expiring entries, big valuesBadger
A tunable LSM engine to build onPebble
SQL, joins or ad hoc queriesSQLite instead
Several processes using the same dataA server database, since all three lock the file to one process

All three open a directory or file exclusively, so only one process can use it. If a second process must read the data, run a server in front, or use SQLite in WAL mode. The projects are at bbolt, Badger and Pebble, and each repository documents its tuning options; pin the module version, as the APIs changed between major versions.

Taggedroundup
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.