Learn how to install River, run migrations to get River’s database schema in place, and create an initial worker and client to start inserting and working jobs.
Prerequisites
River supports PostgreSQL and SQLite. This guide uses PostgreSQL, River’s primary database target. River is tested using the three most recent major versions of PostgreSQL. For experimental SQLite support, see Using with SQLite.
Installation
To install River, run the following in the directory of a Go project (where a go.mod file is present):
go get github.com/riverqueue/rivergo get github.com/riverqueue/river/riverdriver/riverpgxv5Alternatively, the riverdatabasesql driver can be used instead of riverpgxv5 for compatibility with Go’s built-in database/sql. See inserting jobs with Bun or GORM.
Add a River driver and database adapter to your Gemfile (Ruby 3.2 or newer):
gem "riverqueue-sequel"gem "pg"Run bundle install. Rails applications can use riverqueue-activerecord instead of the Sequel driver.
Add River and its serialization and runtime dependencies (Rust 1.95 or newer):
cargo add riverqueuecargo add serde --features derivecargo add tokio --features macros,rt-multi-thread,signalThe default River feature includes Postgres support. River re-exports its compatible SQLx version as riverqueue::sqlx.
Use Node.js 26 or newer, with native Temporal support, and an ESM project. For TypeScript, use TypeScript 6 and @types/node 26 or newer:
npm install riverqueue @riverqueue/driver-pg pg zodnpm install --save-dev typescript @types/node @types/pgKeep River’s core, driver, and optional packages on matching versions.
Running migrations
Run migrations before starting workers. Every language uses the same River schema and migration history; use one migration tool for a database shared by multiple languages. See migrations for deployment and programmatic options.
River persists jobs to a Postgres database, and needs a small set of tables created to insert jobs and carry out leader election. It’s bundled with a command line tool which executes migrations, and which future-proofs River in case other migration steps need to be run in future versions.
From the same directory as above, install the River CLI:
go install github.com/riverqueue/river/cmd/river@latestWith the DATABASE_URL of a target database (looks like postgres://host:5432/db), migrate up:
river migrate-up --database-url "$DATABASE_URL"See also migrations.
Run the bundled river command through Bundler to apply migrations to the database identified by DATABASE_URL:
bundle exec river migrate-up --database-url "$DATABASE_URL"Install riverqueue-cli with Cargo, then use its riverqueue command to apply the database migrations:
cargo install riverqueue-cliriverqueue migrate-up --database-url "$DATABASE_URL"Install the CLI as a development dependency, then run migrate-up against your database:
npm install --save-dev @riverqueue/clinpx riverqueue migrate-up --database-url "$DATABASE_URL"Job args and workers
Each job kind has a stable string identifier, JSON-encoded arguments, and a worker that executes it. Use the same kind and JSON field names when inserting jobs from another language.
Each kind of job in River requires two types: a JobArgs struct and a Worker[T JobArgs]. The JobArgs struct has two purposes:
- It defines the structured arguments for your worker. These arguments are serialized to JSON before the job is stored in the database.
- It defines a
Kind() stringmethod that will be used to uniquely identify the kind of job in the database.
Here is a simple Worker and JobArgs setup for a SortWorker which will sort and print a list of strings provided in its arguments:
type SortArgs struct { // Strings is a slice of strings to sort. Strings []string `json:"strings"`}
func (SortArgs) Kind() string { return "sort" }type SortWorker struct { // An embedded WorkerDefaults sets up default methods to fulfill the rest of // the Worker interface: river.WorkerDefaults[SortArgs]}
func (w *SortWorker) Work(ctx context.Context, job *river.Job[SortArgs]) error { sort.Strings(job.Args.Strings) fmt.Printf("Sorted strings: %+v\n", job.Args.Strings) return nil}Generics
River utilizes Go generics to simplify your Worker definitions. This means that your worker only needs to deal with fully structured and typed set of arguments. As in the example above, a Worker has a 1:1 relationship with the JobArgs type it handles.
Define an args object that supplies the job kind and JSON payload, then a worker with the same kind. The worker reads the decoded arguments from job.args:
require "riverqueue-sequel"
class SortArgs attr_reader :strings
def initialize(strings:) @strings = strings end
def kind = "sort"
def to_json = JSON.generate(strings: strings)end
class SortWorker def self.kind = "sort"
def work(job) puts "Sorted strings: #{job.args.fetch('strings').sort.inspect}" endendDerive JobArgs and Serde’s serialization traits for the args struct, then write an async worker that accepts Job<SortArgs>:
use riverqueue::{BoxError, Job, JobArgs, WorkContext, WorkOutcome};use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, JobArgs, Serialize)]#[river(kind = "sort")]struct SortArgs { strings: Vec<String>,}
async fn sort(_context: WorkContext, mut job: Job<SortArgs>) -> Result<WorkOutcome, BoxError> { job.args.strings.sort(); println!("Sorted strings: {:?}", job.args.strings); Ok(WorkOutcome::Complete)}Use defineJob to give the job a kind and an argument schema. The handler receives those arguments on job.args:
import { Client, Workers, defineJob } from "riverqueue";import { z } from "zod";
const sort = defineJob({ kind: "sort", schema: z.object({ strings: z.array(z.string()) }),});
async function sortStrings({ job }: { job: { args: { strings: string[] } } }) { console.log("Sorted strings:", [...job.args.strings].sort());}Registering workers
Jobs are uniquely identified by their “kind” string. Workers are registered on start up so that River knows how to assign jobs to workers:
workers := river.NewWorkers()
// AddWorker panics if the worker is already registered or invalid:river.AddWorker(workers, &SortWorker{})AddWorker panics in case of invalid configuration. Given its succinct syntax and that bad configuration should prevent a worker process from booting, panicking is probably a reasonable compromise for most applications. However, for those who find it distastely, AddWorkerSafely is also provided:
workers := river.NewWorkers()if err := river.AddWorkerSafely(workers, &SortWorker{}); err != nil { panic("handle this error")}Add the worker class to a River::Workers registry so the client knows which job kinds it can work:
workers = River::Workers.new.add(SortWorker)Register the async worker function with add_fn. Its job argument type identifies the kind it handles:
let mut workers = riverqueue::Workers::new();workers.add_fn(sort)?;Register the job definition and its handler together in Workers:
const workers = new Workers().add(sort, sortStrings);Starting a client
A client inserts jobs, runs workers, and participates in maintenance. This client works the default queue with up to 100 concurrent jobs.
A River Client provides an interface for job insertion and manages job processing and maintenance services. A client is created with a database pool, driver, and config struct containing a Workers bundle and other settings. Here’s a client Client working one queue ("default") with up to 100 worker goroutines at a time:
dbPool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))if err != nil { // handle error}
riverClient, err := river.NewClient(riverpgxv5.New(dbPool), &river.Config{ Queues: map[string]river.QueueConfig{ river.QueueDefault: {MaxWorkers: 100}, }, Workers: workers,})if err != nil { // handle error}
// Run the client inline. All executed jobs will inherit from ctx:if err := riverClient.Start(ctx); err != nil { // handle error}Connect through the Sequel driver and configure the worker registry and queue concurrency before calling start:
DB = Sequel.connect(ENV.fetch("DATABASE_URL"))
client = River::Client.new( River::Driver::Sequel.new(DB), config: River::Config.new(queues: {default: 100}, workers: workers))
client.startBuild a client with the Postgres pool, worker registry, and queue configuration. Keep the run handle returned by start for shutdown:
use riverqueue::{Client, QueueConfig, sqlx::PgPool};
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let client = Client::builder(pool.clone()) .workers(workers) .queue("default", QueueConfig::new(100)) .build()?;
let mut run = client.start()?;Construct a client with a Postgres driver, registered workers, and queue concurrency, then await start to begin working jobs:
import { PgDriver } from "@riverqueue/driver-pg";import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = new Client(new PgDriver(pool), { queues: { default: { maxWorkers: 100 } }, workers,});
const run = await client.start();Stopping
The client should also be stopped on program shutdown:
// Stop fetching new work and wait for active jobs to finish.if err := riverClient.Stop(ctx); err != nil { // handle error}There are some complexities around ensuring clients stop cleanly, but also in a timely manner. Read Graceful shutdown for more details on River’s stop modes.
Insert-only clients will insert jobs, but not work them, and don’t need to be started or stopped.
Stop the client before disconnecting the database so workers can finish and persist their results:
client.stopDB.disconnectAwait stop on the run handle when the application is ready to shut down:
run.stop().await?;Wait for the run to stop before closing the database pool:
await run.stop();await pool.end();Read Graceful shutdown for signal handling and stop modes. Insert-only clients do not need to be started or stopped.
Inserting jobs
Transactional enqueueing keeps jobs consistent with the application data they depend on. A job inserted in a transaction becomes visible to workers only after the transaction commits.
Client.InsertTx is used in conjunction with an instance of job args to insert a job to work on a transaction:
_, err = riverClient.InsertTx(ctx, tx, SortArgs{ Strings: []string{ "whale", "tiger", "bear", },}, nil)if err != nil { // handle error}See the InsertAndWork example for complete code.
Client.Insert that doesn’t take a transaction is also available, although as described in Transactional enqueuing, inserting jobs in transactions is usually more appropriate to avoid bugs.
_, err = riverClient.Insert(ctx, SortArgs{ Strings: []string{ "whale", "tiger", "bear", },}, nil)if err != nil { // handle error}See also Inserting many jobs.
Transactions on the same Sequel database connection automatically include job insertion:
DB.transaction do # Make related application changes here. client.insert SortArgs.new(strings: %w[whale tiger bear])endOutside an application transaction, the same insert call inserts immediately. Active Record transactions work the same way when the driver uses the same connection pool.
Attach an insertion to a SQLx transaction with .tx:
let mut tx = riverqueue::database::begin_postgres(&pool).await?;
// Make related application changes through &mut *tx.client.insert(SortArgs { strings: vec!["whale".into(), "tiger".into(), "bear".into()] }) .tx(&mut tx) .await?;
tx.commit().await?;Omit .tx(&mut tx) for a standalone insertion. Retain a pool handle with pool.clone() when constructing the client. Use River’s transaction helper so beginning a transaction is safe if its future is cancelled.
Pass an open transaction connection as tx to include a job in application changes:
const tx = await pool.connect();
try { await tx.query("BEGIN");
// Make related application changes through tx. await client.insert(sort, { strings: ["whale", "tiger", "bear"] }, { tx });
await tx.query("COMMIT");} catch (error) { await tx.query("ROLLBACK"); throw error;} finally { tx.release();}Omit { tx } for a standalone insertion. River does not begin, commit, or roll back a caller-owned transaction.