Skip to content
gohacks.dev
Databases

Choosing a Database for a New Go Project Without Regret

Choose a database for a new Go project: PostgreSQL, MySQL, SQLite, Redis and MongoDB measured on the same work, and a verdict by what you are building

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

"Which database should we use?" is asked at the start of every project and answered, too often, by whatever the last project used or by a benchmark chart. Neither is a good reason. The databases that Go developers pick from are all fast enough for almost every starting workload, and they differ in what they let you express, what they guarantee and what it costs to run. This article measures one workload on five of them to show how little speed separates them, then gives a way to choose that you can defend a year later.

Tested with Go 1.25.6, pgx v5.11.0, go-sql-driver/mysql v1.9.3, modernc.org/sqlite v1.50.0, go-redis v9.17.0, mongo-driver v2.3.0; PostgreSQL 18.6, MySQL 8.4, Redis 7 and MongoDB 8.3 in Docker on macOS arm64

It belongs to the database cornerstone. The follow-up question, whether to write SQL or use an ORM, is the next article, and each store has a guide: PostgreSQL, MySQL, SQLite, Redis and MongoDB.

The same work on five stores

main.goGo
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"time"

	_ "github.com/go-sql-driver/mysql"
	_ "github.com/jackc/pgx/v5/stdlib"
	"github.com/redis/go-redis/v9"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
	_ "modernc.org/sqlite"
)

const n = 5000

func must(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

type result struct {
	name   string
	insert time.Duration
	read   time.Duration
}

func sqlStore(name, driver, dsn string, create []string, ph func(int) string) result {
	db, err := sql.Open(driver, dsn)
	must(err)
	defer db.Close()
	db.SetMaxOpenConns(1)
	for _, q := range create {
		_, err := db.Exec(q)
		must(err)
	}
	ins := fmt.Sprintf("insert into kv648 (id, v) values (%s, %s)", ph(1), ph(2))
	start := time.Now()
	for i := 0; i < n; i++ {
		_, err := db.Exec(ins, i, fmt.Sprintf("value-%d", i))
		must(err)
	}
	insert := time.Since(start)
	sel := fmt.Sprintf("select v from kv648 where id = %s", ph(1))
	start = time.Now()
	for i := 0; i < n; i++ {
		var v string
		must(db.QueryRow(sel, (i*7919)%n).Scan(&v))
	}
	return result{name, insert, time.Since(start)}
}

func main() {
	ctx := context.Background()
	dir, err := os.MkdirTemp("", "choose648")
	must(err)
	defer os.RemoveAll(dir)
	dollar := func(i int) string { return fmt.Sprintf("$%d", i) }
	qmark := func(int) string { return "?" }

	var results []result
	results = append(results, sqlStore("PostgreSQL 18", "pgx", "postgres://postgres:[email protected]:55432/lab?sslmode=disable",
		[]string{"drop table if exists kv648", "create table kv648 (id int primary key, v text not null)"}, dollar))
	results = append(results, sqlStore("MySQL 8.4", "mysql", "root:secret@tcp(127.0.0.1:33099)/lab",
		[]string{"drop table if exists kv648", "create table kv648 (id int primary key, v varchar(100) not null)"}, qmark))
	results = append(results, sqlStore("SQLite (file, WAL)", "sqlite", "file:"+filepath.Join(dir, "kv.db")+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)",
		[]string{"create table kv648 (id integer primary key, v text not null)"}, qmark))
	results = append(results, sqlStore("SQLite (file, default sync)", "sqlite", "file:"+filepath.Join(dir, "kv2.db"),
		[]string{"create table kv648 (id integer primary key, v text not null)"}, qmark))

	rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:56379"})
	defer rdb.Close()
	must(rdb.FlushDB(ctx).Err())
	start := time.Now()
	for i := 0; i < n; i++ {
		must(rdb.Set(ctx, fmt.Sprint("kv:", i), fmt.Sprintf("value-%d", i), 0).Err())
	}
	ins := time.Since(start)
	start = time.Now()
	for i := 0; i < n; i++ {
		_, err := rdb.Get(ctx, fmt.Sprint("kv:", (i*7919)%n)).Result()
		must(err)
	}
	results = append(results, result{"Redis 7 (in memory)", ins, time.Since(start)})

	client, err := mongo.Connect(options.Client().ApplyURI("mongodb://127.0.0.1:57017"))
	must(err)
	defer client.Disconnect(ctx)
	coll := client.Database("lab").Collection("kv648")
	_ = coll.Drop(ctx)
	start = time.Now()
	for i := 0; i < n; i++ {
		_, err := coll.InsertOne(ctx, bson.D{{Key: "_id", Value: i}, {Key: "v", Value: fmt.Sprintf("value-%d", i)}})
		must(err)
	}
	ins = time.Since(start)
	start = time.Now()
	for i := 0; i < n; i++ {
		var doc bson.M
		must(coll.FindOne(ctx, bson.D{{Key: "_id", Value: (i * 7919) % n}}).Decode(&doc))
	}
	results = append(results, result{"MongoDB 8", ins, time.Since(start)})

	fmt.Printf("%d single row inserts (one statement each), then %d point reads by key, one connection, on one machine\n", n, n)
	fmt.Printf("%-28s %12s %14s %12s %14s\n", "store", "inserts", "per insert", "reads", "per read")
	for _, r := range results {
		fmt.Printf("%-28s %12v %14v %12v %14v\n", r.name, r.insert.Round(time.Millisecond), (r.insert / n).Round(time.Microsecond), r.read.Round(time.Millisecond), (r.read / n).Round(time.Microsecond))
	}
}
Text
5000 single row inserts (one statement each), then 5000 point reads by key, one connection, on one machine
store                             inserts     per insert        reads       per read
PostgreSQL 18                      2.169s          434µs       1.153s          231µs
MySQL 8.4                          7.868s        1.574ms       2.164s          433µs
SQLite (file, WAL)                   73ms           15µs         26ms            5µs
SQLite (file, default sync)        1.753s          351µs         49ms           10µs
Redis 7 (in memory)                1.696s          339µs       1.763s          353µs
MongoDB 8                          2.003s          401µs       2.434s          487µs

The program does the simplest thing a service does, in the simplest way: 5,000 inserts of a small row, one statement and one commit each, then 5,000 point reads by key, from one connection. It runs against containers on the same laptop, so the network is a local bridge, and the code is deliberately plain, with no batching or tuning. The result:

Store5,000 insertsPer insert5,000 readsPer read
PostgreSQL 182.169s434µs1.153s231µs
MySQL 8.47.868s1.574ms2.164s433µs
SQLite, WAL, synchronous(NORMAL)73ms15µs26ms5µs
SQLite, default settings1.753s351µs49ms10µs
Redis 7, in memory1.696s339µs1.763s353µs
MongoDB 82.003s401µs2.434s487µs

Four things stand out, and none of them is a ranking.

The server databases are within a small factor of each other. PostgreSQL, Redis and MongoDB sit between 230 and 490 microseconds per operation, and MySQL is slower on inserts. Almost all of that is a round trip over a socket plus, for writes, a flush to disk. The databases are doing very little work; the lab is measuring the plumbing. MySQL's insert cost is its default of flushing the log on every commit in this container setup, a setting that can be relaxed and then trades durability.

SQLite is faster because there is no network. At 15 microseconds per insert and 5 per read it is an order of magnitude ahead, since the database is a library call in the same process. That is a real advantage for the right workload and says nothing about scale: it has one writer and one machine. Its default settings, on the other hand, took 351 microseconds per insert because every commit syncs the file, which is what WAL mode and synchronous(NORMAL) change.

Redis is not faster for a single call than PostgreSQL here, which surprises people. A trip over a socket costs about the same for both. Redis wins on very high concurrency and on structures like counters and sorted sets, not on the latency of one command.

Single row speed is the wrong criterion. The difference between 230 and 490 microseconds does not show in a web request that also renders a page and calls three services. What shows is whether the database can express your queries, keep your data correct, and be operated by the people you have.

What should decide it

Five questions, in order, settle most projects.

1. What shape is the data? Rows with relationships (customers, orders, products) belong in a relational database. Nested documents read whole by key can live in MongoDB. Counters, sessions, caches and rate limits are Redis's job. Large append only event streams that you aggregate belong in a columnar store. Pick for the shape you have, not the shape you imagine.

2. What must never be wrong? Money, inventory, permissions and anything with invariants want transactions, constraints and foreign keys, which PostgreSQL and MySQL provide and enforce. A store that lets the application enforce invariants will, eventually, have a bug that breaks one. See transaction isolation levels for how much "transaction" is worth.

3. What queries will you want next year? A relational database answers new questions with a new query. A key value store or a key designed store such as DynamoDB answers them with a new table or a migration, and sometimes not at all. If you cannot list the questions, choose the flexible one.

4. Who will run it? A managed PostgreSQL costs money and saves nights. A self hosted cluster of anything costs people. SQLite costs nothing to run and has limits you must respect. Choose what your team can operate at three in the morning, including backups and restores.

5. How big will it be, honestly? Most projects never outgrow a single PostgreSQL server, which handles terabytes and thousands of queries a second. If you expect more, you will know the specific bottleneck and can address it then, with replicas (read replicas), caching (Redis caching patterns) or partitioning (sharding basics). Choosing a distributed database for scale you do not have buys complexity now for a problem you may never meet.

The default, and its limits

Choose PostgreSQL unless you have a reason not to. It does the relational work well, and it also has JSON columns, full text search (search in Postgres from Go), queues with SKIP LOCKED (a Postgres job queue), advisory locks, notifications, row level security, extensions for geospatial data and vectors, and a thoughtful community. One system covers needs that otherwise become three or four, which is the strongest argument for it: every extra database is an extra thing to run, back up, secure and keep consistent.

Reasons to choose something else are real:

  • SQLite when the program is one process on one machine: a CLI, a desktop or mobile app, a small service with modest writes, or a per tenant store. Nothing to run, trivial backups, very fast. Limits: one writer, no network access.
  • MySQL or MariaDB when your organisation already runs and understands it, or a platform you depend on requires it. It is a capable database; do not switch from it without a reason.
  • Redis alongside another database, for caching, rate limits, locks and queues where losing data on a crash is acceptable. Not as the system of record by default.
  • MongoDB when the data is truly documents with varying shapes, read whole, with few relationships, and the team knows how to model for it.
  • DynamoDB, Cassandra, ClickHouse, Elasticsearch when you have the specific workload they exist for: massive key access, huge write rates, analytics scans and search. They are specialists, and a specialist as your only database is a risk.

Avoiding regret

Regret comes from the same few mistakes. Choosing for scale you do not have. Choosing a database that cannot express the query you add in month six. Using a cache as a database. Treating "schemaless" as "no design needed", when the schema moves into code. And mixing several databases without a clear owner of each fact, so the same data lives in three places that disagree. Write down which system is the source of truth for each kind of data and treat the others as copies you can rebuild.

Keep the door open cheaply. Access the database through a thin layer of your own functions, as in small store interfaces, so a migration is a project and not a rewrite. Use plain SQL features and keep the dialect specifics in one place. Keep migrations in version control. And measure your own workload before you believe anyone's chart, including this one: the lab above is an illustration of how close the numbers are, not a benchmark of your system. The PostgreSQL project's own page on what it is good for is a fair statement of the case for the default, and the Go team's guide to accessing databases is the neutral starting point for the code.

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.