---
title: Views
slug: /functions/views
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { CppModuleVersionNotice } from "@site/src/components/CppModuleVersionNotice";


Views are read-only functions that compute and return results from your tables. Unlike [reducers](./00300-reducers/00300-reducers.md), 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.

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

Use the `spacetimedb.view` or `spacetimedb.anonymousView` function:

```typescript
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.

</TabItem>
<TabItem value="csharp" label="C#">

Use the `[SpacetimeDB.View]` attribute on a static method:

```csharp
using SpacetimeDB;

public static partial class Module
{
    [SpacetimeDB.Table]
    public partial struct Player
    {
        [SpacetimeDB.PrimaryKey]
        [SpacetimeDB.AutoInc]
        public ulong Id;

        [SpacetimeDB.Unique]
        public Identity Identity;

        public string Name;
    }

    [SpacetimeDB.Table]
    public partial struct PlayerLevel
    {
        [SpacetimeDB.Unique]
        public ulong PlayerId;

        [SpacetimeDB.Index.BTree]
        public ulong Level;
    }

    [SpacetimeDB.Type]
    public partial struct PlayerAndLevel
    {
        public ulong Id;
        public Identity Identity;
        public string Name;
        public ulong Level;
    }

    // At-most-one row: return T?
    [SpacetimeDB.View(Accessor = "MyPlayer", Public = true)]
    public static Player? MyPlayer(ViewContext ctx)
    {
        return ctx.Db.Player.Identity.Find(ctx.Sender) as Player?;
    }

    // Multiple rows: return a list or enumerable
    [SpacetimeDB.View(Accessor = "PlayersForLevel", Public = true)]
    public static IEnumerable<PlayerAndLevel> PlayersForLevel(AnonymousViewContext ctx)
    {
        var rows = new List<PlayerAndLevel>();
        foreach (var player in ctx.Db.PlayerLevel.Level.Filter(1))
        {
            if (ctx.Db.Player.Id.Find(player.PlayerId) is Player p)
            {
                var row = new PlayerAndLevel
                {
                    Id = p.Id,
                    Identity = p.Identity,
                    Name = p.Name,
                    Level = player.Level
                };
                rows.Add(row);
            }
        }
        return rows;
    }
}
```

Views must be static methods and can return either a single row (`T?`) or multiple rows (`IEnumerable<T>`, `List<T>`, or `T[]`) where `T` can be a table type or any product type.

</TabItem>
<TabItem value="rust" label="Rust">

Use the `#[spacetimedb::view]` macro on a function:

```rust
use spacetimedb::{view, ViewContext, AnonymousViewContext, table, SpacetimeType};
use spacetimedb_lib::Identity;

#[spacetimedb::table(accessor = player)]
pub struct Player {
    #[primary_key]
    #[auto_inc]
    id: u64,
    #[unique]
    identity: Identity,
    name: String,
}

#[spacetimedb::table(accessor = player_level)]
pub struct PlayerLevel {
    #[unique]
    player_id: u64,
    #[index(btree)]
    level: u64,
}

#[derive(SpacetimeType)]
pub struct PlayerAndLevel {
    id: u64,
    identity: Identity,
    name: String,
    level: u64,
}

// At-most-one row: return Option<T>
#[view(accessor = my_player, public)]
fn my_player(ctx: &ViewContext) -> Option<Player> {
    ctx.db.player().identity().find(ctx.sender())
}

// Multiple rows: return Vec<T>
#[view(accessor = players_for_level, public)]
fn players_for_level(ctx: &AnonymousViewContext) -> Vec<PlayerAndLevel> {
    ctx.db
        .player_level()
        .level()
        .filter(2u64)
        .flat_map(|player| {
            ctx.db
                .player()
                .id()
                .find(player.player_id)
                .map(|p| PlayerAndLevel {
                    id: p.id,
                    identity: p.identity,
                    name: p.name,
                    level: player.level,
                })
        })
        .collect()
}
```

Views can return either `Option<T>` for at-most-one row or `Vec<T>` for multiple rows, where `T` can be a table type or any product type.

</TabItem>
<TabItem value="cpp" label="C++">

<CppModuleVersionNotice />

Use the `SPACETIMEDB_VIEW` macro:

```cpp
#include <spacetimedb.h>
using namespace SpacetimeDB;

struct Player {
    uint64_t id;
    Identity identity;
    std::string name;
};
SPACETIMEDB_STRUCT(Player, id, identity, name)
SPACETIMEDB_TABLE(Player, player, Public)
FIELD_PrimaryKeyAuto(player, id)
FIELD_Unique(player, identity)

struct PlayerLevel {
    uint64_t player_id;
    uint64_t level;
};
SPACETIMEDB_STRUCT(PlayerLevel, player_id, level)
SPACETIMEDB_TABLE(PlayerLevel, player_level, Public)
FIELD_Unique(player_level, player_id)
FIELD_Index(player_level, level)

struct PlayerAndLevel {
    uint64_t id;
    std::string name;
    uint64_t level;
};
SPACETIMEDB_STRUCT(PlayerAndLevel, id, name, level)

// At-most-one row: return std::optional<T>
SPACETIMEDB_VIEW(std::optional<Player>, my_player, Public, ViewContext ctx) {
    return ctx.db[player_identity].find(ctx.sender());
}

// Multiple rows: return std::vector<T>
SPACETIMEDB_VIEW(std::vector<PlayerAndLevel>, players_for_level, Public, AnonymousViewContext ctx) {
    std::vector<PlayerAndLevel> results;
    
    // Find all player levels with level == 2
    for (auto player_lv : ctx.db[player_level_level].filter(uint64_t(2))) {
        auto p = ctx.db[player_id].find(player_lv.player_id);
        if (p) {
            results.push_back(PlayerAndLevel{p->id, p->name, player_lv.level});
        }
    }
    
    return results;
}
```

Views can return either `std::optional<T>` for at-most-one row or `std::vector<T>` for multiple rows, where `T` can be a table type or any product type.

</TabItem>
</Tabs>

## 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 Case | Recommended Context | Why |
|----------|-------------------|-----|
| Global leaderboard | `AnonymousViewContext` | Same top-10 for everyone |
| Shop inventory | `AnonymousViewContext` | Same items available to all |
| My inventory | `ViewContext` | Different per player |
| My messages | `ViewContext` | Private to each user |
| World map regions | `AnonymousViewContext` | Geographic 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.

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
// 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;
  }
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
// Per-user: each client sees their own player
[SpacetimeDB.View(Accessor = "MyPlayer", Public = true)]
public static Player? MyPlayer(ViewContext ctx)
{
    return ctx.Db.Player.Identity.Find(ctx.Sender);
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
// Per-user: each client sees their own player
#[view(accessor = my_player, public)]
fn my_player(ctx: &ViewContext) -> Option<Player> {
    ctx.db.player().identity().find(&ctx.sender())
}
```

</TabItem>
<TabItem value="cpp" label="C++">

```cpp
// Per-user: each client sees their own player
SPACETIMEDB_VIEW(std::optional<Player>, my_player, Public, ViewContext ctx) {
    return ctx.db[player_identity].find(ctx.sender());
}
```

</TabItem>
</Tabs>

### 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.

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
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 }));
  }
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
    [SpacetimeDB.PrimaryKey]
    [SpacetimeDB.AutoInc]
    public ulong Id;
    public string Name;
    [SpacetimeDB.Index.BTree]
    public ulong Score;
}

// Shared: same high scorers for all clients
[SpacetimeDB.View(Accessor = "HighScorers", Public = true)]
public static List<Player> HighScorers(AnonymousViewContext ctx)
{
    // Get all players with score >= 1000 using the btree index
    return ctx.Db.Player.Score.Filter((1000, ulong.MaxValue)).ToList();
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
    #[primary_key]
    #[auto_inc]
    id: u64,
    name: String,
    #[index(btree)]
    score: u64,
}

// Shared: same high scorers for all clients
#[view(accessor = high_scorers, public)]
fn high_scorers(ctx: &AnonymousViewContext) -> Vec<Player> {
    // Get all players with score >= 1000 using the btree index
    ctx.db.player().score().filter(1000..).collect()
}
```

</TabItem>
<TabItem value="cpp" label="C++">

```cpp
struct Player {
    uint64_t id;
    std::string name;
    uint64_t score;
};
SPACETIMEDB_STRUCT(Player, id, name, score)
SPACETIMEDB_TABLE(Player, player, Public)
FIELD_PrimaryKeyAutoInc(player, id)
FIELD_Index(player, score)

// Shared: same high scorers for all clients
SPACETIMEDB_VIEW(std::vector<Player>, high_scorers, Public, AnonymousViewContext ctx) {
    return ctx.db[player_high_scorers_score].filter(range_from(uint64_t(1000))).collect();
}
```

</TabItem>
</Tabs>

### 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.

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
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);
  }
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
using SpacetimeDB;

public partial class Module
{
    [SpacetimeDB.Table(Accessor = "Entity", Public = true)]
    public partial struct Entity
    {
        [SpacetimeDB.PrimaryKey]
        [SpacetimeDB.AutoInc]
        public ulong Id;
        [SpacetimeDB.Index.BTree]
        public int ChunkX;
        [SpacetimeDB.Index.BTree]
        public int ChunkY;
        public float LocalX;
        public float LocalY;
        public string EntityType;
    }

    // Track which chunks each player is subscribed to
    [SpacetimeDB.Table(Accessor = "PlayerChunk", Public = true)]
    public partial struct PlayerChunk
    {
        [SpacetimeDB.PrimaryKey]
        public ulong PlayerId;
        public int ChunkX;
        public int ChunkY;
    }

    // Shared: all players in chunk (0,0) share this view
    [SpacetimeDB.View(Accessor = "EntitiesInOriginChunk", Public = true)]
    public static List<Entity> EntitiesInOriginChunk(AnonymousViewContext ctx)
    {
        // All entities in chunk (0, 0) - shared by everyone viewing this chunk
        return ctx.Db.Entity.ChunkX.Filter(0)
            .Where(e => e.ChunkY == 0)
            .ToList();
    }

    // Per-user: returns entities in the chunk the player is currently in
    [SpacetimeDB.View(Accessor = "EntitiesInMyChunk", Public = true)]
    public static List<Entity> EntitiesInMyChunk(ViewContext ctx)
    {
        if (ctx.Db.Player.Identity.Find(ctx.Sender) is not Player player)
        {
            return new List<Entity>();
        }

        if (ctx.Db.PlayerChunk.PlayerId.Find(player.Id) is not PlayerChunk chunk)
        {
            return new List<Entity>();
        }

        return ctx.Db.Entity.ChunkX.Filter(chunk.ChunkX)
            .Where(e => e.ChunkY == chunk.ChunkY)
            .ToList();
    }
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
use spacetimedb::{view, AnonymousViewContext, ViewContext};

#[spacetimedb::table(accessor = entity, public)]
pub struct Entity {
    #[primary_key]
    #[auto_inc]
    id: u64,
    #[index(btree)]
    chunk_x: i32,
    #[index(btree)]
    chunk_y: i32,
    local_x: f32,
    local_y: f32,
    entity_type: String,
}

#[spacetimedb::table(accessor = player_chunk, public)]
pub struct PlayerChunk {
    #[primary_key]
    player_id: u64,
    chunk_x: i32,
    chunk_y: i32,
}

// Shared: all players in chunk (0,0) share this view
#[view(accessor = entities_in_origin_chunk, public)]
fn entities_in_origin_chunk(ctx: &AnonymousViewContext) -> Vec<Entity> {
    ctx.db.entity().chunk_x().filter(&0)
        .filter(|e| e.chunk_y == 0)
        .collect()
}

// Per-user: returns entities in the chunk the player is currently in
#[view(accessor = entities_in_my_chunk, public)]
fn entities_in_my_chunk(ctx: &ViewContext) -> Vec<Entity> {
    let Some(player) = ctx.db.player().identity().find(&ctx.sender()) else {
        return vec![];
    };
    let Some(chunk) = ctx.db.player_chunk().player_id().find(&player.id) else {
        return vec![];
    };

    ctx.db.entity().chunk_x().filter(&chunk.chunk_x)
        .filter(|e| e.chunk_y == chunk.chunk_y)
        .collect()
}
```

</TabItem>
<TabItem value="cpp" label="C++">

```cpp
struct Entity {
    uint64_t id;
    int32_t chunk_x;
    int32_t chunk_y;
    float local_x;
    float local_y;
    std::string entity_type;
};
SPACETIMEDB_STRUCT(Entity, id, chunk_x, chunk_y, local_x, local_y, entity_type)
SPACETIMEDB_TABLE(Entity, entity, Public)
FIELD_PrimaryKeyAutoInc(entity, id)
FIELD_Index(entity, chunk_x)
FIELD_Index(entity, chunk_y)

// Track which chunks each player is subscribed to
struct PlayerChunk {
    uint64_t player_id;
    int32_t chunk_x;
    int32_t chunk_y;
};
SPACETIMEDB_STRUCT(PlayerChunk, player_id, chunk_x, chunk_y)
SPACETIMEDB_TABLE(PlayerChunk, player_chunk, Public)
FIELD_PrimaryKey(player_chunk, player_id)

// Shared: all players in chunk (0,0) share this view
SPACETIMEDB_VIEW(std::vector<Entity>, entities_in_origin_chunk, Public, AnonymousViewContext ctx) {
    std::vector<Entity> results;
    
    // All entities in chunk (0, 0) - shared by everyone viewing this chunk
    for (auto entity : ctx.db[entity_chunk_x].filter(int32_t(0))) {
        if (entity.chunk_y == 0) {
            results.push_back(entity);
        }
    }
    
    return results;
}

// Per-user: returns entities in the chunk the player is currently in
SPACETIMEDB_VIEW(std::vector<Entity>, entities_in_my_chunk, Public, ViewContext ctx) {
    std::vector<Entity> results;
    
    // Find the player's current location
    auto player_opt = ctx.db[player_identity].find(ctx.sender());
    if (!player_opt) {
        return results;  // Player not found
    }
    
    // Find which chunk the player is in
    auto chunk_opt = ctx.db[player_chunk_player_id].find(player_opt->id);
    if (!chunk_opt) {
        return results;  // Player has no chunk assigned
    }
    
    // Return all entities in the player's chunk
    for (auto entity : ctx.db[entity_chunk_x].filter(chunk_opt->chunk_x)) {
        if (entity.chunk_y == chunk_opt->chunk_y) {
            results.push_back(entity);
        }
    }
    
    return results;
}
```

</TabItem>
</Tabs>

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.

### Example: Counts

Procedural views can call `count()` or `Count` on a table accessor to read the current number of rows in a table.
This works with both `ViewContext` and `AnonymousViewContext`.

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
const playerCountRow = t.row('PlayerCountRow', {
    count: t.u64(),
});

export const player_count = spacetimedb.anonymousView(
    { name: 'player_count', public: true },
    t.array(playerCountRow),
    (ctx) => [{ count: ctx.db.players.count() }]
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
[SpacetimeDB.Type]
public partial struct PlayerCountRow
{
    public ulong Count;
}

[SpacetimeDB.View(Accessor = "PlayerCount", Public = true)]
public static List<PlayerCountRow> PlayerCount(AnonymousViewContext ctx)
{
    return new List<PlayerCountRow>
    {
        new PlayerCountRow { Count = ctx.Db.Player.Count }
    };
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
use spacetimedb::{view, AnonymousViewContext, SpacetimeType};

#[derive(SpacetimeType)]
pub struct PlayerCountRow {
    count: u64,
}

#[view(accessor = player_count, public)]
fn player_count(ctx: &AnonymousViewContext) -> Vec<PlayerCountRow> {
    vec![PlayerCountRow {
        count: ctx.db.player().count(),
    }]
}
```

</TabItem>
<TabItem value="cpp" label="C++">

```cpp
struct PlayerCountRow {
    uint64_t count;
};
SPACETIMEDB_STRUCT(PlayerCountRow, count)

SPACETIMEDB_VIEW(std::optional<PlayerCountRow>, player_count, Public, AnonymousViewContext ctx) {
    return std::optional<PlayerCountRow>(PlayerCountRow{ctx.db[player].count()});
}
```

</TabItem>
</Tabs>

`count()` is a constant-time metadata lookup, so it does not iterate the table.
`count()` requires re-evaluation whenever any row of the underlying table is updated, however that re-evaluation is still cheap because `count()` is cheap.

## Querying Views

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

```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) and table-level metadata lookups like `count()`, 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

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
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'));
  }
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
[SpacetimeDB.View(Accessor = "HighScorers", Public = true)]
public static IQuery<Player> HighScorers(AnonymousViewContext ctx)
{
    return ctx.From.Player()
        .Where(p => p.Score.Gte(1000UL))
        .Where(p => p.Name.Neq("BOT"));
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
#[view(accessor = high_scorers, public)]
fn high_scorers(ctx: &AnonymousViewContext) -> impl Query<Player> {
    ctx.from
        .player()
        .r#where(|p| p.score.gte(1000u64))
        .r#where(|p| p.name.ne("BOT"))
}
```

</TabItem>
</Tabs>

### 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.

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
// 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));
  }
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
// Procedural: row-by-row join in module code.
[SpacetimeDB.View(Accessor = "HighScoringModeratorsProcedural", Public = true)]
public static List<Player> HighScoringModeratorsProcedural(AnonymousViewContext ctx)
{
    var results = new List<Player>();
    foreach (var player in ctx.Db.Player.Score.Filter((100, ulong.MaxValue)))
    {
        if (ctx.Db.Moderator.PlayerId.Find(player.Id) is Moderator)
        {
            results.Add(player);
        }
    }
    return results;
}

// 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.
[SpacetimeDB.View(Accessor = "HighScoringModerators", Public = true)]
public static IQuery<Player> HighScoringModerators(AnonymousViewContext ctx)
{
    return ctx.From.Player()
        .Where(p => p.Score.Gte(100UL))
        .LeftSemijoin(ctx.From.Moderator(), (p, m) => p.Id.Eq(m.PlayerId));
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
// Procedural: row-by-row join in module code.
#[view(accessor = high_scoring_moderators_procedural, public)]
fn high_scoring_moderators_procedural(ctx: &AnonymousViewContext) -> Vec<Player> {
    let mut out = Vec::new();
    for p in ctx.db.player().score().filter(100..) {
        if ctx.db.moderator().player_id().find(p.id).is_some() {
            out.push(p);
        }
    }
    out
}

// 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.
#[view(accessor = high_scoring_moderators_declarative, public)]
fn high_scoring_moderators_declarative(ctx: &AnonymousViewContext) -> impl Query<Player> {
    ctx.from
        .player()
        .r#where(|p| p.score.gte(100u64))
        .left_semijoin(ctx.from.moderator(), |p, m| p.id.eq(m.player_id))
}
```

</TabItem>
</Tabs>

### 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.

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
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
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
using SpacetimeDB;

public partial class Module
{
    [SpacetimeDB.Table(Accessor = "Player", Public = true)]
    public partial struct Player
    {
        [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc]
        public ulong Id;

        public string Name;
        [SpacetimeDB.Index.BTree] public ulong Score;
    }

    [SpacetimeDB.Table(Accessor = "PlayerLevel", Public = true)]
    public partial struct PlayerLevel
    {
        [SpacetimeDB.Unique] public ulong PlayerId;
        [SpacetimeDB.Index.BTree] public ulong Level;
    }

    [SpacetimeDB.View(Accessor = "AllPlayers", Public = true)]
    public static IQuery<Player> AllPlayers(AnonymousViewContext ctx)
    {
        return ctx.From.Player();
    }

    [SpacetimeDB.View(Accessor = "AllPlayerLevels", Public = true)]
    public static IQuery<PlayerLevel> AllPlayerLevels(AnonymousViewContext ctx)
    {
        return ctx.From.PlayerLevel();
    }
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
use spacetimedb::{table, view, AnonymousViewContext, Query};

#[spacetimedb::table(name = player, public)]
pub struct Player {
    #[primary_key]
    #[auto_inc]
    id: u64,
    name: String,
    #[index(btree)]
    score: u64,
}

#[spacetimedb::table(name = player_level, public)]
pub struct PlayerLevel {
    #[unique]
    player_id: u64,
    #[index(btree)]
    level: u64,
}

#[view(accessor = all_players, public)]
fn all_players(ctx: &AnonymousViewContext) -> impl Query<Player> {
    ctx.from.player()
}

#[view(accessor = all_player_levels, public)]
fn all_player_levels(ctx: &AnonymousViewContext) -> impl Query<PlayerLevel> {
    ctx.from.player_level()
}
```

</TabItem>
</Tabs>

#### `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

| Operation | Rust | TypeScript | C# |
|----------|------|------------|----|
| Equal | `eq` | `eq` | `Eq` |
| Not equal | `ne` | `ne` | `Neq` |
| Less than | `lt` | `lt` | `Lt` |
| Less than or equal | `lte` | `lte` | `Lte` |
| Greater than | `gt` | `gt` | `Gt` |
| Greater than or equal | `gte` | `gte` | `Gte` |

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
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'));
  }
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
[SpacetimeDB.View(Accessor = "HighScorers", Public = true)]
public static IQuery<Player> HighScorers(AnonymousViewContext ctx)
{
    return ctx.From.Player()
        .Where(p => p.Score.Gte(1000UL))
        .Filter(p => p.Name.Neq("BOT"));
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
#[view(accessor = high_scorers, public)]
fn high_scorers(ctx: &AnonymousViewContext) -> impl Query<Player> {
    ctx.from
        .player()
        .r#where(|p| p.score.gte(1000u64))
        .filter(|p| p.name.ne("BOT"))
}
```

</TabItem>
</Tabs>

##### Boolean combinators

Combine conditions with `and`, `or`, and `not`:

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
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());
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
ctx.From.Player().Where(p => p.Score.Gte(1000UL).And(p.Score.Lt(5000UL)));
ctx.From.Player().Where(p => p.Name.Eq("ADMIN").Or(p.Name.Eq("BOT")));
ctx.From.Player().Where(p => p.Name.Eq("BOT").Not());
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
ctx.from.player().r#where(|p| p.score.gte(1000u64).and(p.score.lt(5000u64)));
ctx.from.player().r#where(|p| p.name.eq("ADMIN").or(p.name.eq("BOT")));
ctx.from.player().r#where(|p| p.name.eq("BOT").not());
```

</TabItem>
</Tabs>

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

<Tabs groupId="server-language" queryString>
<TabItem value="typescript" label="TypeScript">

```typescript
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));
  }
);
```

</TabItem>
<TabItem value="csharp" label="C#">

```csharp
[SpacetimeDB.View(Accessor = "PlayersWithLevels", Public = true)]
public static IQuery<Player> PlayersWithLevels(AnonymousViewContext ctx)
{
    return ctx.From.Player()
        .LeftSemijoin(ctx.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId));
}

[SpacetimeDB.View(Accessor = "LevelsForHighScorers", Public = true)]
public static IQuery<PlayerLevel> LevelsForHighScorers(AnonymousViewContext ctx)
{
    return ctx.From.Player()
        .Where(p => p.Score.Gte(1000UL))
        .RightSemijoin(ctx.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId))
        .Where(pl => pl.Level.Gte(10UL));
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
#[view(accessor = players_with_levels, public)]
fn players_with_levels(ctx: &AnonymousViewContext) -> impl Query<Player> {
    ctx.from
        .player()
        .left_semijoin(ctx.from.player_level(), |p, pl| p.id.eq(pl.player_id))
}

#[view(accessor = levels_for_high_scorers, public)]
fn levels_for_high_scorers(ctx: &AnonymousViewContext) -> impl Query<PlayerLevel> {
    ctx.from
        .player()
        .r#where(|p| p.score.gte(1000u64))
        .right_semijoin(ctx.from.player_level(), |p, pl| p.id.eq(pl.player_id))
        .r#where(|pl| pl.level.gte(10u64))
}
```

</TabItem>
</Tabs>

### Primary Key Inference for Query Builder Views

Query builder views may carry primary-key semantics from their underlying row type.
When a view's row type maps to a table with a primary key,
generated client bindings can treat the view like a primary-key table for client-cache updates.
In particular, update callbacks (`on_update` / `OnUpdate` / `onUpdate`) will be generated for these view handles.
If a primary key cannot be inferred for the view row type, clients fall back to insert/delete-only behavior for that view handle.

## Next Steps

- Review [Subscriptions](../00400-subscriptions.md) for real-time client data access
