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

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';


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

// At-most-one row: return Option<row> via t.option(...)
// Your function may return the row or null
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(...)
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(Name = "MyPlayer", Public = true)]
    public static Player? MyPlayer(ViewContext ctx)
    {
        return ctx.Db.Player.Identity.Find(ctx.Sender) as Player;
    }

    // Multiple rows: return a list
    [SpacetimeDB.View(Name = "PlayersForLevel", Public = true)]
    public static List<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 a list of rows (`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(name = player)]
pub struct Player {
    #[primary_key]
    #[auto_inc]
    id: u64,
    #[unique]
    identity: Identity,
    name: String,
}

#[spacetimedb::table(name = 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(name = my_player, public)]
fn my_player(ctx: &ViewContext) -> Option<Player> {
    ctx.db.player().identity().find(ctx.sender)
}

// Multiple rows: return Vec<T>
#[view(name = 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>
</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`, the result depends on `ctx.sender`, so 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
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(Name = "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(name = my_player, public)]
fn my_player(ctx: &ViewContext) -> Option<Player> {
    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);

// Shared: same high scorers for all clients
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(Name = "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(Name = "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(name = 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(name = 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>
</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
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
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(Name = "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(Name = "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(Name = "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(Name = "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(name = 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(name = 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(name = 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(name = 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>
</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.

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

**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 on the 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:

- Complex views with multiple joins can be expensive to compute
- Views are recomputed when rows in their read set change
- Subscriptions to views will receive updates even if the final result doesn't change

## Next Steps

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