> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.docs.xano.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Data and tables

> Define tables with typed columns, foreign keys, indexes and seed rows, read and write them with related rows attached by addons, and react to row changes with table triggers.

A table is a `table({ name, schema })` def object. The schema is a map of column name to an
`f.*` field, and the row type follows from it.

## Define tables

```ts theme={null}
// xano/tables/blog.ts
import { table, f } from "@xano/sdk";

export const users = table({
  name: "users",
  auth: true, // an auth table: endpoints can require a signed-in user from it
  schema: {
    email: f.email({ required: true }),
    name: f.text(),
    password: f.password(), // hashed on write
    role: f.enum(["admin", "member"], { default: "member" }),
  },
  index: [{ type: "unique", fields: [{ name: "email" }] }],
});

export const posts = table({
  name: "posts",
  schema: {
    title: f.text({ required: true }),
    body: f.text(),
    author_id: f.tableRef(users, { required: true }), // foreign key to users
    published: f.bool({ default: "false" }),
    tags: f.text({ array: true }), // a list column: string[]
  },
  index: [{ type: "btree", fields: [{ name: "author_id" }] }],
});
```

* `id` and `created_at` are added at the head of every schema unless you declare them.
  `idType: "uuid"` gives a uuid primary key instead of an int.
* `f.tableRef(table)` links to another table. The target must be registered on the same
  workspace, or the export fails and names it.
* `{ array: true }` turns any scalar into a list column.
* `"unique"` in an index is shorthand for `"btree|unique"`.

Register both in `xano/index.ts` with `.registerTables([users, posts])`.

## Seed rows

Give a table `seed` rows and they are written when you deploy, so a fresh Xano Engine or
ephemeral starts with data:

```ts theme={null}
export const categories = table({
  name: "categories",
  schema: {
    slug: f.text({ required: true }),
    label: f.text({ required: true }),
  },
  seed: [
    { slug: "news", label: "News" },
    { slug: "guides", label: "Guides" },
  ],
});
```

Rows are type-checked against the schema. Omit `id` and rows are numbered for you. For a larger
set, keep the rows in a file with `seed: seedFile("./categories.seed.json", import.meta.url)`.
Seed data is fixture data: keep secrets out of it.

## Read and write rows

Endpoints, functions, tasks and triggers reach tables through the `s.db.*` statements:

```ts theme={null}
import { query, input, inp, ref, s, c, col, expr } from "@xano/sdk";
import { posts } from "../tables/blog.js";
import { blog } from "./blog.js"; // the API group, see APIs, functions and middleware

export const listPublished = query({
  name: "posts",
  verb: "GET",
  apiGroup: blog,
  stack: [
    s.db.query({
      table: posts,
      where: expr(col("published"), "=", c.bool(true)),
      sort: [{ sortBy: "created_at", dir: "desc" }],
      paging: { per_page: 20, metadata: false }, // a bare row list, not a paging envelope
      as: "rows",
    }),
  ],
  response: ref("rows"),
});

export const publishPost = query({
  name: "posts/{id}/publish",
  verb: "POST",
  apiGroup: blog,
  input: { id: input.int({ required: true }) },
  stack: [
    // edit writes only the columns you list and leaves the rest as stored
    s.db.edit({ table: posts, fieldValue: inp("id"), row: { published: true }, as: "post" }),
  ],
  response: ref("post"),
});
```

What each statement binds decides the endpoint's response type:

| Statement | Binds |
| - | - |
| `s.db.get` | the row, or `null` on a miss (it does not throw) |
| `s.db.add`, `s.db.edit`, `s.db.patch` | the full written row, with `id` and `created_at` |
| `s.db.query` | a row list, or a paging envelope when `paging` keeps its metadata |
| `s.db.del` | `null`; `edit` and `del` throw a 404 when nothing matches |

`s.db.bulk.update` replaces every column an item omits with its zero value. Use
`s.db.bulk.patch` to update only the keys each item carries.

## Related rows: addons

To return each post with its author, attach an `addon` to the read. Do not run a second query
and loop over the rows to stitch them together. An addon is a single read against one table,
bound to the parent row through its inputs:

```ts theme={null}
// xano/tables/addons.ts
import { addon, input, inp, col, expr } from "@xano/sdk";
import { users } from "./blog.js";

export const author = addon({
  name: "author",
  table: users,
  input: { user_id: input.int({ required: true }) },
  where: expr(col("id"), "=", inp("user_id")), // binds it to the parent row
  output: ["id", "name"],
  cardinality: "single", // one object per row; "list", "count" and "exists" also work
});
```

Attach it with the read's `addon` option, and map the parent row's columns into its inputs
with `out()`:

```ts theme={null}
import { query, ref, s, c, col, expr, out } from "@xano/sdk";
import { posts } from "../tables/blog.js";
import { author } from "../tables/addons.js";
import { blog } from "./blog.js";

export const listPublished = query({
  name: "posts",
  verb: "GET",
  apiGroup: blog,
  stack: [
    s.db.query({
      table: posts,
      where: expr(col("published"), "=", c.bool(true)),
      paging: { per_page: 20, metadata: false },
      // each row gains _author: { id, name } | null
      addon: [{ addon: author, as: "_author", input: { user_id: out("author_id") } }],
      as: "rows",
    }),
  ],
  response: ref("rows"),
});
```

Register the addon with `.registerAddons([author])`. `s.db.get`, `add`, `edit` and `patch`
take the same `addon` option. Passing the handle types the graft in the endpoint's response, so
the [typed frontend](/build/typed-frontend) sees `_author`. An `as` that shadows a real column
fails the build, which is why the alias starts with `_`.

## React to row changes: table triggers

A `tableTrigger` runs a stack when rows in its table are inserted, updated, deleted or
truncated. It fires on every write, whichever endpoint, function or task made it. Put the
same side effect in one endpoint's stack and every other write path skips it.

A trigger has no `input`. Its `stack` is a callback that receives a handle `t`: `t.new` is the
row after the change, `t.old` the row before it, and `t.action` the operation.

```ts theme={null}
// xano/triggers/posts.ts
import { tableTrigger, table, f, s, c, col, expr } from "@xano/sdk";
import { posts } from "../tables/blog.js";

export const postEvents = table({
  name: "post_events",
  schema: { post_id: f.int(), title: f.text(), event: f.text() },
});

export const onPostChange = tableTrigger({
  name: "on_post_change",
  table: posts,
  actions: { insert: true, update: true, delete: true },
  stack: (t) => [
    s.conditional({
      when: { left: t.action, op: "=", right: c.text("delete") },
      // a delete has no new row, so read the old one
      then: [s.db.add({ table: postEvents, row: { post_id: t.old("id"), title: t.old("title"), event: t.action } })],
      else: [s.db.add({ table: postEvents, row: { post_id: t.new("id"), title: t.new("title"), event: t.action } })],
    }),
  ],
});

// search filters rows in the database, so it names NEW and OLD columns with col(), not t.
export const onPublish = tableTrigger({
  name: "on_post_publish",
  table: posts,
  actions: { update: true },
  search: expr(col("NEW.published"), "=", c.bool(true)),
  stack: (t) => [s.debug.log({ value: t.new("title") })],
});
```

Register them with `.registerTriggers([onPostChange, onPublish])`.

* **Bind `table` to the handle.** `t.new("title")` and `t.old("title")` are then typed to the
  posts row, and a misspelled column does not compile.
* **The absent side is `{}`, not `null`.** An insert's `t.old` and a delete's `t.new` are empty
  objects, so branch on `t.action` as above rather than on a null check.
* **`search` runs before the stack.** It is the same `expr()` surface as a `where`, with
  columns addressed as `NEW.<column>` and `OLD.<column>`. It cannot be combined with
  `truncate`.
* **Triggers return nothing.** A table trigger is config only, with no `response`.

To run one on demand while you develop, ask your coding agent to run it through the Xano
Engine's [MCP server](/run/engine-mcp).

## Use the row type

`InferRow` gives you a table's row type anywhere in TypeScript:

```ts theme={null}
import type { InferRow } from "@xano/sdk";
import type { posts } from "./tables/blog.js";

type Post = InferRow<typeof posts>; // id, created_at, title, body, author_id, published, tags
```

Rename or retype a column and every consumer stops compiling. The
[typed frontend](/build/typed-frontend) uses the same types.

## Changing a table later

`npm run xano:deploy` merges into the Xano Engine's existing data, and prints what a schema
change will cost (dropped columns, retyped columns) before it writes. Table changes that reach a
shared workspace are not rolled back by a later release, so read
[Schema changes](/govern/schema-changes) before you drop or retype a column there.

## Next

* Joins, addon cardinalities, aggregates, vector search and every column type:
  [authoring reference](/reference/authoring).
* Every trigger type's actions and handle: [object kinds reference](/reference/object-kinds).
* Put these tables behind endpoints and functions:
  [APIs, functions and middleware](/build/apis-and-middleware).
* Work on a schedule instead of on a write: [Tasks](/build/tasks).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.