Overview
Reducers are functions that modify database state in response to client requests or system events. They are the only way to mutate tables in SpacetimeDB - all database changes must go through reducers.
Defining Reducers
Reducers are defined in your module code and automatically exposed as callable functions to connected clients.
- TypeScript
- C#
- Rust
Use the spacetimedb.reducer function:
import { schema, table, t } from 'spacetimedb/server';
spacetimedb.reducer('create_user', { name: t.string(), email: t.string() }, (ctx, { name, email }) => {
// Validate input
if (name === '') {
throw new Error('Name cannot be empty');
}
// Modify tables
ctx.db.user.insert({
id: 0, // auto-increment will assign
name,
email
});
});The first argument is the reducer name, the second defines argument types, and the third is the handler function taking (ctx, args).
Use the [SpacetimeDB.Reducer] attribute on a static method:
using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreateUser(ReducerContext ctx, string name, string email)
{
// Validate input
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException("Name cannot be empty");
}
// Modify tables
ctx.Db.User.Insert(new User
{
Id = 0, // auto-increment will assign
Name = name,
Email = email
});
}
}Reducers must be static methods with ReducerContext as the first parameter. Additional parameters must be types marked with [SpacetimeDB.Type]. Reducers should return void.
Use the #[spacetimedb::reducer] macro on a function:
use spacetimedb::{reducer, ReducerContext, Table};
#[reducer]
pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> {
// Validate input
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
// Modify tables
ctx.db.user().insert(User {
id: 0, // auto-increment will assign
name,
email,
});
Ok(())
}Reducers must take &ReducerContext as their first parameter. Additional parameters must be serializable types. Reducers can return (), Result<(), String>, or Result<(), E> where E: Display.
Table operations like insert, try_insert, iter, and count are provided by the Table trait. You must import this trait for these methods to be available:
use spacetimedb::Table;If you see errors like "no method named try_insert found", add this import.
Transactional Execution
Every reducer runs inside a database transaction. This provides important guarantees:
- Isolation: Reducers don't see changes from other concurrent reducers
- Atomicity: Either all changes succeed or all are rolled back
- Consistency: Failed reducers leave the database unchanged
If a reducer throws an exception or returns an error, all of its changes are automatically rolled back.
Accessing Tables
Reducers have full read-write access to all tables (both public and private) through the ReducerContext. The examples below assume a user table with id (primary key), name (indexed), and email (unique) columns.
Inserting Rows
- TypeScript
- C#
- Rust
ctx.db.user.insert({
id: 0, // auto-increment will assign
name: 'Alice',
email: 'alice@example.com'
});ctx.Db.User.Insert(new User
{
Id = 0, // auto-increment will assign
Name = "Alice",
Email = "alice@example.com"
});ctx.db.user().insert(User {
id: 0, // auto-increment will assign
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
});Finding Rows by Unique Column
Use find on a unique or primary key column to retrieve a single row:
- TypeScript
- C#
- Rust
const user = ctx.db.user.id.find(123);
if (user) {
console.log(`Found: ${user.name}`);
}
const byEmail = ctx.db.user.email.find('alice@example.com');var user = ctx.Db.User.Id.Find(123);
if (user is not null)
{
Log.Info($"Found: {user.Name}");
}
var byEmail = ctx.Db.User.Email.Find("alice@example.com");if let Some(user) = ctx.db.user().id().find(123) {
log::info!("Found: {}", user.name);
}
let by_email = ctx.db.user().email().find("alice@example.com");Filtering Rows by Indexed Column
Use filter on an indexed column to retrieve multiple matching rows:
- TypeScript
- C#
- Rust
for (const user of ctx.db.user.name.filter('Alice')) {
console.log(`User ${user.id}: ${user.email}`);
}foreach (var user in ctx.Db.User.Name.Filter("Alice"))
{
Log.Info($"User {user.Id}: {user.Email}");
}for user in ctx.db.user().name().filter("Alice") {
log::info!("User {}: {}", user.id, user.email);
}Updating Rows
Find a row, modify it, then call update on the same unique column:
- TypeScript
- C#
- Rust
const user = ctx.db.user.id.find(123);
if (user) {
user.name = 'Bob';
ctx.db.user.id.update(user);
}var user = ctx.Db.User.Id.Find(123);
if (user is not null)
{
user.Name = "Bob";
ctx.Db.User.Id.Update(user);
}if let Some(mut user) = ctx.db.user().id().find(123) {
user.name = "Bob".to_string();
ctx.db.user().id().update(user);
}Deleting Rows
Delete by unique column value or by indexed column value:
- TypeScript
- C#
- Rust
// Delete by primary key
ctx.db.user.id.delete(123);
// Delete all matching an indexed column
const deleted = ctx.db.user.name.delete('Alice');
console.log(`Deleted ${deleted} row(s)`);// Delete by primary key
ctx.Db.User.Id.Delete(123);
// Delete all matching an indexed column
var deleted = ctx.Db.User.Name.Delete("Alice");
Log.Info($"Deleted {deleted} row(s)");// Delete by primary key
ctx.db.user().id().delete(123);
// Delete all matching an indexed column
let deleted = ctx.db.user().name().delete("Alice");
log::info!("Deleted {} row(s)", deleted);Iterating All Rows
Use iter to iterate over all rows in a table:
- TypeScript
- C#
- Rust
for (const user of ctx.db.user.iter()) {
console.log(`${user.id}: ${user.name}`);
}foreach (var user in ctx.Db.User.Iter())
{
Log.Info($"{user.Id}: {user.Name}");
}for user in ctx.db.user().iter() {
log::info!("{}: {}", user.id, user.name);
}Counting Rows
Use count to get the number of rows in a table:
- TypeScript
- C#
- Rust
const total = ctx.db.user.count();
console.log(`Total users: ${total}`);var total = ctx.Db.User.Count();
Log.Info($"Total users: {total}");let total = ctx.db.user().count();
log::info!("Total users: {}", total);For more details on querying with indexes, including range queries and multi-column indexes, see Indexes.
Reducer Isolation
Reducers run in an isolated environment and cannot interact with the outside world:
- ❌ No network requests
- ❌ No file system access
- ❌ No system calls
- ✅ Only database operations
If you need to interact with external systems, use Procedures instead. Procedures can make network calls and perform other side effects, but they have different execution semantics and limitations.
Relying on global variables, static variables, or module-level state to persist across reducer calls is undefined behavior. SpacetimeDB does not guarantee that values stored in these locations will be available in subsequent reducer invocations.
This is undefined for several reasons:
- Fresh execution environments. SpacetimeDB may run each reducer in a fresh WASM or JS instance.
- Module updates. Publishing a new module creates a fresh execution environment. This is necessary for hot-swapping modules while transactions are in flight.
- Concurrent execution. SpacetimeDB reserves the right to execute multiple reducers concurrently in separate execution environments (e.g., with MVCC).
- Crash recovery. Instance memory is not persisted across restarts.
- Non-transactional updates. If you modify global state and then roll back the transaction, the modified value may remain for subsequent transactions.
- Replay safety. If a serializability anomaly is detected, SpacetimeDB may re-execute your reducer with the same arguments, causing modifications to global state to occur multiple times.
Reducers are designed to be free of side effects. They should only modify tables. Always store state in tables to ensure correctness and durability.
// ❌ Undefined behavior: may or may not persist or correctly update across reducer calls
static mut COUNTER: u64 = 0;
// ✅ Store state in a table instead
#[spacetimedb::table(name = counter)]
pub struct Counter {
#[primary_key]
id: u32,
value: u64,
}Scheduling Procedures
Reducers cannot call procedures directly (procedures may have side effects incompatible with transactional execution). Instead, schedule a procedure to run by inserting into a schedule table:
- TypeScript
- C#
- Rust
import { schema, t, table, SenderError } from 'spacetimedb/server';
// Define a schedule table for the procedure
const fetchSchedule = table(
{ name: 'fetch_schedule', scheduled: 'fetch_external_data' },
{
scheduled_id: t.u64().primaryKey().autoInc(),
scheduled_at: t.scheduleAt(),
url: t.string(),
}
);
const spacetimedb = schema(fetchSchedule);
// The procedure to be scheduled
const fetchExternalData = spacetimedb.procedure(
'fetch_external_data',
{ arg: fetchSchedule.rowType },
t.unit(),
(ctx, { arg }) => {
const response = ctx.http.fetch(arg.url);
// Process response...
return {};
}
);
// From a reducer, schedule the procedure by inserting into the schedule table
const queueFetch = spacetimedb.reducer('queue_fetch', { url: t.string() }, (ctx, { url }) => {
ctx.db.fetchSchedule.insert({
scheduled_id: 0n,
scheduled_at: ScheduleAt.interval(0n), // Run immediately
url,
});
});#pragma warning disable STDB_UNSTABLE
using SpacetimeDB;
public partial class Module
{
[SpacetimeDB.Table(Name = "FetchSchedule", Scheduled = "FetchExternalData", ScheduledAt = "ScheduledAt")]
public partial struct FetchSchedule
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong ScheduledId;
public ScheduleAt ScheduledAt;
public string Url;
}
[SpacetimeDB.Procedure]
public static void FetchExternalData(ProcedureContext ctx, FetchSchedule schedule)
{
var result = ctx.Http.Get(schedule.Url);
if (result is Result<HttpResponse, HttpError>.OkR(var response))
{
// Process response...
}
}
// From a reducer, schedule the procedure
[SpacetimeDB.Reducer]
public static void QueueFetch(ReducerContext ctx, string url)
{
ctx.Db.FetchSchedule.Insert(new FetchSchedule
{
ScheduledId = 0,
ScheduledAt = new ScheduleAt.Interval(TimeSpan.Zero),
Url = url,
});
}
}use spacetimedb::{ScheduleAt, ReducerContext, ProcedureContext, Table};
use std::time::Duration;
#[spacetimedb::table(name = fetch_schedule, scheduled(fetch_external_data))]
pub struct FetchSchedule {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: ScheduleAt,
url: String,
}
#[spacetimedb::procedure]
fn fetch_external_data(ctx: &mut ProcedureContext, schedule: FetchSchedule) {
if let Ok(response) = ctx.http.get(&schedule.url) {
// Process response...
}
}
// From a reducer, schedule the procedure
#[spacetimedb::reducer]
fn queue_fetch(ctx: &ReducerContext, url: String) {
ctx.db.fetch_schedule().insert(FetchSchedule {
scheduled_id: 0,
scheduled_at: ScheduleAt::Interval(Duration::ZERO.into()),
url,
});
}See Schedule Tables for more scheduling options.
Next Steps
- Learn about Tables to understand data storage
- Explore Procedures for side effects beyond the database
- Review Subscriptions for real-time client updates