Skip to main content
Version: 2.0.0-rc1

Views

Views are read-only functions that compute and return results from your tables. Unlike reducers, views do not modify database state - they only query and return data. Views are useful for computing derived data, aggregations, or joining multiple tables before sending results to clients.

Why Use Views?

Views provide several benefits:

  • Performance: Views compute results server-side, reducing the amount of data sent to clients
  • Encapsulation: Views can hide complex queries behind simple interfaces
  • Consistency: Views ensure clients receive consistently formatted data
  • Real-time updates: Like tables, views can be subscribed to and automatically update when underlying data changes

Defining Views

Views must be declared as public with an explicit name, and they accept only a context parameter - no user-defined arguments beyond the context type.

Use the spacetimedb.view or spacetimedb.anonymousView function:

import { schema, table, t } from 'spacetimedb/server';

const players = table(
  { name: 'players', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    identity: t.identity().unique(),
    name: t.string(),
  }
);

const playerLevels = table(
  { name: 'player_levels', public: true },
  {
    player_id: t.u64().unique(),
    level: t.u64().index('btree'),
  }
);

const spacetimedb = schema({ players, playerLevels });
export default spacetimedb;

// At-most-one row: return Option<row> via t.option(...)
// Your function may return the row or null
export const my_player = spacetimedb.view(
    { name: 'my_player', public: true },
    t.option(players.rowType),
    (ctx) => {
        const row = ctx.db.players.identity.find(ctx.sender());
        return row ?? undefined;
    }
);

// Define a custom row type for the joined result
const playerAndLevelRow = t.row('PlayerAndLevel', {
    id: t.u64(),
    name: t.string(),
    level: t.u64(),
});

// Multiple rows: return an array of rows via t.array(...)
export const players_for_level = spacetimedb.anonymousView(
    { name: 'players_for_level', public: true },
    t.array(playerAndLevelRow),
    (ctx) => {
        const out: Array<{ id: bigint; name: string; level: bigint }> = [];
        for (const playerLevel of ctx.db.playerLevels.level.filter(2n)) {
            const p = ctx.db.players.id.find(playerLevel.player_id);
            if (p) out.push({ id: p.id, name: p.name, level: playerLevel.level });
        }
        return out;
    }
);

The handler signature is (ctx) => rows, where rows must be either an array or option of product values.

ViewContext and AnonymousViewContext

Views use one of two context types:

  • ViewContext: Provides access to the caller's Identity through ctx.sender(). Use this when the view depends on who is querying it.
  • AnonymousViewContext: Does not provide caller information. Use this when the view produces the same results regardless of who queries it.

Both contexts provide read-only access to tables and indexes through ctx.db.

Performance: Why AnonymousViewContext Matters

The choice between ViewContext and AnonymousViewContext has significant performance implications.

Anonymous views can be shared across all subscribers. When a view uses AnonymousViewContext, SpacetimeDB knows the result is the same for every client. The database can materialize the view once and serve that same result to all subscribers. When the underlying data changes, it recomputes the view once and broadcasts the update to everyone.

Per-user views require separate computation for each subscriber. When a view uses ViewContext and invokes ctx.sender(), each client potentially sees different data. SpacetimeDB must compute and track the view separately for each subscriber. With 1,000 connected users, that's 1,000 separate view computations and 1,000 separate sets of change tracking.

Prefer AnonymousViewContext when possible. Design your views to be caller-independent when the use case allows. For example:

Use CaseRecommended ContextWhy
Global leaderboardAnonymousViewContextSame top-10 for everyone
Shop inventoryAnonymousViewContextSame items available to all
My inventoryViewContextDifferent per player
My messagesViewContextPrivate to each user
World map regionsAnonymousViewContextGeographic data shared by all nearby players

Design around shared data when you can. Sometimes a small design change lets you use anonymous views. For example, instead of a view that returns "entities near me" (which requires knowing who "me" is), consider views that return "entities in region X". Multiple players in the same region share a single materialized view rather than each having their own.

Example: Per-User View

This view returns the caller's own player data. Each connected client sees different results, so SpacetimeDB must track it separately for each subscriber.

// Per-user: each client sees their own player
export const my_player = spacetimedb.view(
  { name: 'my_player', public: true },
  t.option(players.rowType),
  (ctx) => {
    return ctx.db.players.identity.find(ctx.sender) ?? undefined;
  }
);

Example: High Scores Leaderboard

This view returns players with scores above a threshold. Every client sees the same results, so SpacetimeDB computes it once and shares it across all subscribers. The view uses a btree index on the score column to efficiently find high-scoring players.

const players = table(
  { name: 'players', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    name: t.string(),
    score: t.u64().index('btree'),
  }
);

const spacetimedb = schema({ players });
export default spacetimedb;

// Shared: same high scorers for all clients
export const high_scorers = spacetimedb.anonymousView(
  { name: 'high_scorers', public: true },
  t.array(players.rowType),
  (ctx) => {
    // Get all players with score >= 1000 using the btree index
    return Array.from(ctx.db.players.score.filter({ gte: 1000n }));
  }
);

Example: Region-Based Design

Instead of querying "what's near me" (per-user), design your data model so clients subscribe to shared regions. This example shows entities organized by chunk coordinates.

const entity = table(
  { name: 'entity', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    chunkX: t.i32().index('btree'),
    chunkY: t.i32().index('btree'),
    localX: t.f32(),
    localY: t.f32(),
    entityType: t.string(),
  }
);

// Track which chunks each player is subscribed to
const playerChunk = table(
  { name: 'player_chunk', public: true },
  {
    playerId: t.u64().primaryKey(),
    chunkX: t.i32(),
    chunkY: t.i32(),
  }
);

// Shared: all players in chunk (0,0) share this view
export const entities_in_origin_chunk = spacetimedb.anonymousView(
  { name: 'entities_in_origin_chunk', public: true },
  t.array(entity.rowType),
  (ctx) => {
    // All entities in chunk (0, 0) - shared by everyone viewing this chunk
    return Array.from(ctx.db.entity.chunkX.filter(0))
      .filter(e => e.chunkY === 0);
  }
);

// Per-user: returns entities in the chunk the player is currently in
export const entities_in_my_chunk = spacetimedb.view(
  { name: 'entities_in_my_chunk', public: true },
  t.array(entity.rowType),
  (ctx) => {
    const player = ctx.db.players.identity.find(ctx.sender);
    if (!player) return [];

    const chunk = ctx.db.playerChunk.playerId.find(player.id);
    if (!chunk) return [];

    return Array.from(ctx.db.entity.chunkX.filter(chunk.chunkX))
      .filter(e => e.chunkY === chunk.chunkY);
  }
);

The entities_in_origin_chunk view is shared - if 100 players are all looking at chunk (0,0), SpacetimeDB computes it once. The entities_in_my_chunk view requires per-user computation since each player may be in a different chunk.

For games with many players in the same area, the shared approach scales much better. Clients can subscribe to the specific chunk views they need based on their position, and players in the same chunk automatically share the same materialized data.

Querying Views

Views can be queried and subscribed to just like normal tables using SQL:

SELECT * FROM my_player;
SELECT * FROM players_for_level;

When subscribed to, views automatically update when their underlying tables change, providing real-time updates to clients.

Why Views Cannot Use .iter()

You may notice that views can only access table data through indexed lookups (.find() and .filter() on indexed columns), not through .iter() which scans all rows. This is a deliberate design choice for performance.

Views are black boxes. View functions are Turing-complete code that SpacetimeDB cannot analyze or optimize. When a view reads from a table, SpacetimeDB tracks the "read set" - which rows the view accessed. If any row in that read set changes, the view's output might have changed, so SpacetimeDB must re-execute the entire view function.

Full table scans create pessimistic read sets. If a view used .iter() to scan an entire table, its read set would include every row in that table. This means any change to any row - even rows unrelated to the view's output - would trigger a complete re-evaluation of the view. For a table with millions of rows, this becomes prohibitively expensive.

Index lookups enable targeted invalidation. When a view uses .find() or .filter() on an indexed column, SpacetimeDB knows exactly which rows the view depends on. If a row outside that set changes, the view doesn't need to be re-evaluated. This keeps view updates fast and predictable.

Why SQL subscriptions can scan. You might wonder why SQL subscription queries can include full table scans while view functions cannot. The difference is that SQL queries are not black boxes - SpacetimeDB can analyze and transform them. The query engine uses incremental evaluation: when rows change, it computes exactly which output rows are affected without re-running the entire query. Think of it like taking the derivative of the query - given a small change in input, compute the small change in output. Since view functions are opaque code, this kind of incremental computation isn't possible.

Why query builder subscriptions can scan. For the same reason that SQL subscriptions can scan. Anything you can do with SQL subscriptions you can do with the query builder API and vice versa.

The tradeoff is acceptable for indexed access. For point lookups (.find()) and small range scans (.filter() on indexed columns), the performance difference between full re-evaluation and incremental evaluation is small. This is why views are limited to indexed access - it's the subset of operations where the black-box limitation doesn't hurt performance.

If you need to aggregate or sort entire tables, consider returning a Query from your view instead. Since queries can be analyzed by the query engine, they support incremental evaluation even when scanning full tables. Alternatively, design your schema so the data you need is accessible through indexes.

Performance Considerations

Views compute results server side, which can improve performance by:

  • Reducing network traffic by filtering/aggregating before sending data
  • Avoiding redundant computation on multiple clients
  • Leveraging server-side indexes and query optimization

However, keep in mind that:

  • View functions are reevaluated when SpacetimeDB detects something they read has since changed (the read set)
  • The key to optimizing a view function is keeping its read set small
  • View functions can have large read sets when they read from non-unique indexes and/or multiple tables without using the query builder
  • View functions are executed largely as written by the WASM/V8 runtimes with little room for global optimization
  • Join-heavy view functions will incur extra row materialization costs when serializing data across the WASM/V8 boundary

If a view is primarily performing query logic like filtering and joining rows, it is recommended to use the module-side query builder, since it pushes work into the query engine, which can optimize and evaluate the plan more efficiently.

Query Builder in Views

Example

export const high_scorers = spacetimedb.anonymousView(
  { name: 'high_scorers', public: true },
  t.array(players.rowType),
  (ctx) => {
    return ctx.from.players
      .where(p => p.score.gte(1000n))
      .where(p => p.name.ne('BOT'));
  }
);

Why Use the Query Builder?

Both ViewContext and AnonymousViewContext expose a builder API for expressing query logic like filters and joins. The module-side query builder is designed for view logic that is mostly filters and joins. These views are evaluated by the query engine instead of the WASM/V8 runtime, the benefits of which include the following:

  • The query engine can apply global optimizations that WASM and V8 cannot, drastically improving the performance of your views
  • These views can be updated incrementally; the database does not have to re-evaluate the entire view if its read set changes
  • These views avoid the row materialization costs incurred by procedural view functions every time your code fetches/reads a row from the database

For join-heavy views, this difference is often significant.

In the example below, we have players and moderators. The procedural version must execute as written: iterate filtered rows from players, then probe moderators row by row. The query builder version expresses the same relationship declaratively, which means the query engine is free to choose a better plan, including reordering the join and starting from moderators if it thinks that will be more efficient.

// Procedural: row-by-row join in module code.
export const high_scoring_moderators_procedural = spacetimedb.anonymousView(
  { name: 'high_scoring_moderators_procedural', public: true },
  t.array(players.rowType),
  (ctx) => {
    return Array.from(ctx.db.players.score.filter({ gte: 100n }))
      .filter(p => ctx.db.moderators.player_id.find(p.id) != null);
  }
);

// Query builder: equivalent logic pushed to the query engine.
// The engine can reorder this join if it decides the smaller side is a better starting point.
export const high_scoring_moderators_declarative = spacetimedb.anonymousView(
  { name: 'high_scoring_moderators_declarative', public: true },
  t.array(players.rowType),
  (ctx) => {
    return ctx.from.players
      .where(p => p.score.gte(100n))
      .leftSemijoin(ctx.from.moderators, (p, m) => p.id.eq(m.player_id));
  }
);

API Reference

Table Accessors

The query builder is available only through ViewContext and AnonymousViewContext: There is an accessor method/property for each table defined in your module.

import { schema, table, t } from 'spacetimedb/server';

const players = table(
  { name: 'players', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    name: t.string(),
    score: t.u64().index('btree'),
  }
);

const playerLevels = table(
  { name: 'player_levels', public: true },
  {
    player_id: t.u64().unique(),
    level: t.u64().index('btree'),
  }
);

const spacetimedb = schema({ players, playerLevels });
export default spacetimedb;

export const all_players = spacetimedb.anonymousView(
  { name: 'all_players', public: true },
  t.array(players.rowType),
  (ctx) => ctx.from.players
);

export const all_player_levels = spacetimedb.anonymousView(
  { name: 'all_player_levels', public: true },
  t.array(playerLevels.rowType),
  (ctx) => ctx.from.playerLevels
);

where / filter

Use where to apply predicates. Chaining multiple filters combines them with logical AND.

filter is an alias for where in Rust and C#.

Comparison operators
OperationRustTypeScriptC#
EqualeqeqEq
Not equalneneNeq
Less thanltltLt
Less than or equalltelteLte
Greater thangtgtGt
Greater than or equalgtegteGte
export const high_scorers = spacetimedb.anonymousView(
  { name: 'high_scorers', public: true },
  t.array(players.rowType),
  (ctx) => {
    return ctx.from.players
      .where(p => p.score.gte(1000n))
      .where(p => p.name.ne('BOT'));
  }
);
Boolean combinators

Combine conditions with and, or, and not:

ctx.from.players.where(p => p.score.gte(1000n).and(p.score.lt(5000n)));
ctx.from.players.where(p => p.name.eq('ADMIN').or(p.name.eq('BOT')));
ctx.from.players.where(p => p.name.eq('BOT').not());

Comparisons are strongly typed, meaning invalid comparisons are rejected by the type system. An example would be comparing an Identity column to an integer literal.

Semijoins

Semijoins keep rows from one side when a matching row exists on the other side. They are used for filtering rows in one table based on rows in the other table.

  • A left semijoin returns rows from the left side that have at least one match on the right side.
  • A right semijoin returns rows from the right side that have at least one match on the left side.
  • Filters chained before a semijoin apply to the pre-join source side.
  • Filters chained after a semijoin apply to whichever side the semijoin returns.
  • Like where/filter, join predicates are strongly typed
  • Additionally join predicates may only use indexed columns with multi-column indexes not supported at the moment
export const players_with_levels = spacetimedb.anonymousView(
  { name: 'players_with_levels', public: true },
  t.array(players.rowType),
  (ctx) => {
    return ctx.from.players
      .leftSemijoin(ctx.from.playerLevels, (p, pl) => p.id.eq(pl.player_id));
  }
);

export const levels_for_high_scorers = spacetimedb.anonymousView(
  { name: 'levels_for_high_scorers', public: true },
  t.array(playerLevels.rowType),
  (ctx) => {
    return ctx.from.players
      .where(p => p.score.gte(1000n))
      .rightSemijoin(ctx.from.playerLevels, (p, pl) => p.id.eq(pl.player_id))
      .where(pl => pl.level.gte(10n));
  }
);

Next Steps