# River: background jobs in Go with Postgres as the queue

> River is a Go job processing library that keeps jobs in the same Postgres database as application data, so enqueueing happens inside the transaction that creates the work. This review covers the worker model, installation, limits, and who should skip it.

**riverqueue/river** — Fast and reliable background jobs in Go

- Repository: https://github.com/riverqueue/river
- Website: https://riverqueue.com
- Stars: 5,720 · Forks: 184
- Language: Go
- License: MPL-2.0
- Published: 2026-09-22 · Updated: 2026-09-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/riverqueue-river

## The problem River solves: jobs that must commit with the data

The classic failure mode in a queued system is the gap between a database write and a queue write. A request writes a row, then pushes a message to a broker. If the process dies between the two, the row exists with no job, or the job exists with no row. River's answer is to remove the second system. Jobs live in Postgres tables, and the README states that by enqueueing jobs transactionally along with other database changes, whole classes of distributed systems problems are avoided. The claim is specific: jobs are guaranteed to be enqueued if their transaction commits, are removed if their transaction rolls back, and are not visible for work until commit.

That last clause is the interesting one. A worker cannot pick up a job whose creating transaction is still open, so a long-running transaction does not leak half-finished work into the queue. The audience is Go teams already running Postgres who have felt the dual-write problem, and who would rather add tables than add a broker to their operational surface.

## How River works: job args, workers, and a client that polls Postgres

River has three moving parts. Job arguments are a struct with json annotations plus a Kind method returning a stable string. Workers are a struct embedding river.WorkerDefaults parameterized on those args, with a Work method that receives a context and a *river.Job. The client is created with a driver, a config containing a Workers bundle and queue settings, and then started.

Workers are registered into a river.NewWorkers() bundle with river.AddWorker, which the README says panics if the worker is already registered or invalid. Registration is what lets the client check at insert time that a job kind has a worker capable of running it, which is why the README recommends including the Workers bundle even though the field can be omitted.

Queues are configured as a map from queue name to a QueueConfig with a MaxWorkers count. The README's example runs one queue, "default", with up to 100 worker goroutines. Beyond the core loop, the repository lists batch insertion using Postgres COPY FROM, periodic and cron jobs, scheduled jobs, snoozing and cancelling from inside a work function, unique jobs by args, period, queue and state, and subscriptions to queue activity for logging and metrics. There is also a separate River UI project for inspecting queues, and a cmd/river command in the repository used for migrations.

## Installing River and running a first job

River is a Go module. The module path is github.com/riverqueue/river, and go.mod in the repository shows the pgx v5 driver as a separate module, github.com/riverqueue/river/riverdriver/riverpgxv5, at the same version as the core module. The README's examples import both.

The repository's Makefile shows how the project itself prepares a database, and the same command is what an application needs to run to create River's tables. It drops and recreates a development database and then runs migrations through the repository's own CLI:

```bash
dropdb river_dev --force --if-exists
createdb river_dev
cd cmd/river && go run . migrate-up --database-url "postgres://localhost/river_dev"
```

After migrating, define a job and a worker. The README gives this pair, which sorts a slice of strings:

```go
type SortArgs struct {
	Strings []string `json:"strings"`
}

func (SortArgs) Kind() string { return "sort" }

type SortWorker struct {
	river.WorkerDefaults[SortArgs]
}

func (w *SortWorker) Work(ctx context.Context, job *river.Job[SortArgs]) error {
	sort.Strings(job.Args.Strings)
	return nil
}
```

Register the worker, build a client against a pgx pool, and start it. The README notes that AddWorker panics on a duplicate or invalid worker, so registration mistakes surface at startup rather than at job time:

```go
workers := river.NewWorkers()
river.AddWorker(workers, &SortWorker{})

riverClient, err := river.NewClient(riverpgxv5.New(dbPool), &river.Config{
	Queues: map[string]river.QueueConfig{
		river.QueueDefault: {MaxWorkers: 100},
	},
	Workers: workers,
})
if err != nil {
	panic(err)
}
if err := riverClient.Start(ctx); err != nil {
	panic(err)
}
```

Insertion inside a transaction uses Client.InsertTx with the transaction, the args, and options (nil for defaults). If the surrounding transaction rolls back, the job never becomes visible. For shutdown, the README's shortest path is to pass a context cancelled on SIGINT or SIGTERM to Start and wait on riverClient.Stopped(), with SoftStopTimeout controlling how long active jobs get before they are cancelled.

## Where River is the wrong choice

River's central constraint is also its selling point: the queue is your application database. That means job traffic competes with query traffic for the same connections, the same disk, and the same vacuum. A workload that enqueues millions of short jobs a day will show up in Postgres metrics that application teams watch closely, and the README offers no partitioning or off-database mode to move that load elsewhere.

The second constraint is that workers are Go. The README documents cross-language enqueueing for Python and Ruby, but only for insertion; the jobs are worked by Go implementations. A polyglot team that wants a Python consumer and a Go consumer on the same queue is outside what the README describes.

Third, River requires schema migrations. The Makefile's db/reset target exists because the project manages its own tables, and an application adopting River has to run those migrations before the client can work. Teams that cannot run migrations on their production database, or that treat schema changes as a quarterly event, will find that requirement awkward. Finally, the README documents no rollback procedure for the client or for its migrations, so downgrade behaviour is something to establish from the migration files rather than from the documentation.

## River compared with a broker-backed queue

The obvious alternative class is a broker-backed job system, where a Redis, RabbitMQ or similar process holds the queue and workers pull from it. The difference is not performance, it is the failure boundary. With a broker, enqueueing is a second write that can succeed while the database write fails, or the reverse, and reconciling those cases is application code. With River, the README says there is no second write to reconcile, because the job row and the application row commit together. The price is that the queue scales with Postgres and not independently of it.

Within Go, the README itself names the lineage River draws on: Oban in Elixir, Que, Sidekiq, Delayed::Job and GoodJob in Ruby, and Hangfire in .NET. Those are the same design idea in other runtimes, and several of them also use the application database. The relevant comparison for a Go team is less "River versus Sidekiq" than "do I want the database-backed model at all", and River is a fairly direct expression of it.

## Maintenance, versioning and licence

The repository is not archived, and the last push was on 2026-09-22. Releases are frequent and versioned: v0.47.0 on 2026-08-31, v0.46.0 on 2026-08-29, and v0.45.0 on 2026-08-25. The 0.x version number is the thing to plan around. River's own modules are pinned to matching versions in go.mod (riverdriver, riverdriver/riverpgxv5, rivershared and rivertype all at v0.47.0), which suggests the submodules move together, so an upgrade means bumping several modules and applying any new migrations rather than changing one line.

The licence is MPL-2.0, a file-level copyleft licence. Modifying River's own files carries obligations for those files; using the library as a dependency from your own Go code is the ordinary case the project is built for. This is not legal advice, and teams with licence review processes should route the MPL-2.0 text in the repository's LICENSE file through them. The README also points to a hosted homepage, documentation site and a River UI project, but the README does not state which parts of that surface are open source and which are commercial, so that question needs checking at the source rather than assumed.

## Conclusion

Adopt River if your Go service already writes to Postgres and you want job insertion to commit or roll back with the surrounding transaction. Do not adopt it if you need a broker with cross-language workers, or if you cannot run database migrations in production. Before committing, verify that the migration path from cmd/river fits your deployment process and that your Postgres version is supported by the migration set you plan to apply.

## FAQ

### What is River in the context of Go and Postgres?

River is a job processing system for Go that stores jobs in Postgres. Jobs are defined as struct pairs (job args plus a worker), registered on a client, and inserted either directly or inside a database transaction.

### Does River need a separate queue service like Redis?

No. The README states River is built for Postgres and encourages using the same database for application data and the job queue, so there is no second system holding the queue.

### How do I install River in a Go project?

It is a Go module at github.com/riverqueue/river, added with go get, alongside the pgx v5 driver module github.com/riverqueue/river/riverdriver/riverpgxv5. The repository's Makefile shows the migrate-up command used to create the tables.

### Can jobs be enqueued from Python or Ruby with River?

The README documents inserting jobs from Python and Ruby, but says those jobs are then worked by Go implementations. Insertion is cross-language; execution is not.

### What licence does River use?

The repository's LICENSE file is MPL-2.0, a file-level copyleft licence. The README does not discuss licence terms, so the file itself is the reference.

## Sources

- [License: MPL-2.0](https://github.com/riverqueue/river/blob/master/LICENSE)
- [Project website](https://riverqueue.com)
- [README](https://github.com/riverqueue/river/blob/master/README.md)
- [Releases](https://github.com/riverqueue/river/releases)
- [riverqueue/river on GitHub](https://github.com/riverqueue/river)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/riverqueue-river
