# Using the database from a function

> Read and write your SupaHost project's Postgres from an Edge Function with supabase-js, as the signed-in user (RLS applies) or as service_role, plus transactions via RPC.

Source: https://supahost.app/docs/functions/database-access · Updated 2026-11-11

Edge Functions on SupaHost reach the database through the Data API, using supabase-js. Functions don't get a `SUPABASE_DB_URL`, and Postgres isn't reachable over the network from outside the instance (only Business and Enterprise plans can connect directly, through an SSH tunnel). So skip Postgres drivers like `postgres.js` or `pg` in functions and use the client below.

That's rarely a limitation. Anything you'd do in one round trip with SQL, you can put in a Postgres function and call with `rpc`.

## Two kinds of client

| Client | Key | Row Level Security | Use for |
|---|---|---|---|
| **User client** | `SUPABASE_ANON_KEY` plus the caller's `Authorization` header | Applies, as the signed-in user | Anything done on behalf of a user |
| **Admin client** | `SUPABASE_SERVICE_ROLE_KEY` | Bypassed | Webhooks, cron jobs, cross-user work |

Prefer the user client. It means your RLS policies keep protecting data even if the function has a bug.

### User client

```ts
import { createClient } from "jsr:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
  );

  // Only returns this user's rows, if the table has a policy such as
  // create policy "Own recipes" on recipes for select to authenticated
  //   using ((select auth.uid()) = user_id);
  const { data, error } = await supabase
    .from("recipes")
    .select("id, title, tags")
    .contains("tags", ["vegetarian"])
    .limit(20);

  if (error) return Response.json({ error: error.message }, { status: 400 });
  return Response.json(data);
});
```

Create the user client inside the handler, because the header changes with each request.

### Admin client

```ts
import { createClient } from "jsr:@supabase/supabase-js@2";

// Module scope: created once per worker and reused across requests.
const admin = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  { auth: { persistSession: false, autoRefreshToken: false } },
);

Deno.serve(async () => {
  const cutoff = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
  const { count, error } = await admin
    .from("draft_recipes")
    .delete({ count: "exact" })
    .lt("updated_at", cutoff);

  if (error) return Response.json({ error: error.message }, { status: 500 });
  return Response.json({ deleted: count });
});
```

The admin client ignores RLS completely. Pair it with **Require a JWT** plus a role check, or a webhook signature, so random callers can't use it (see [Authentication and JWT verification](/docs/functions/auth)). The admin client can also call `admin.auth.admin.*` to manage users; see [Managing users (admin API)](/docs/auth/user-management).

## Several writes in one transaction

Each supabase-js call is its own HTTP request and its own transaction. When writes must succeed or fail together, move them into a Postgres function and call it with `rpc`. Create it with a migration or Studio's SQL editor:

```sql
create or replace function public.book_table(p_restaurant_id bigint, p_slot timestamptz, p_guests int)
returns bigint
language plpgsql
security invoker
set search_path = ''
as $$
declare
  v_booking_id bigint;
begin
  update public.slots
     set seats_left = seats_left - p_guests
   where restaurant_id = p_restaurant_id
     and starts_at = p_slot
     and seats_left >= p_guests;

  if not found then
    raise exception 'Not enough seats left';
  end if;

  insert into public.bookings (restaurant_id, starts_at, guests, user_id)
  values (p_restaurant_id, p_slot, p_guests, (select auth.uid()))
  returning id into v_booking_id;

  return v_booking_id;
end;
$$;
```

```ts
const { data: bookingId, error } = await supabase.rpc("book_table", {
  p_restaurant_id: 12,
  p_slot: "2026-11-20T19:30:00Z",
  p_guests: 4,
});
if (error) return Response.json({ error: error.message }, { status: 409 });
```

The function runs as the caller (`security invoker`), so RLS still applies to both statements. More in [Calling database functions (RPC)](/docs/api/rpc) and [Database functions](/docs/database/functions).

## Things to know about the Data API

- **Exposed schemas are fixed:** `public`, `storage`, `graphql_public` and `pgmq_public`. Tables in other schemas need a view or function in `public`. See [Exposed schemas](/docs/api/exposed-schemas).
- **At most 1000 rows per request.** Page through bigger results with `.range(from, to)`.
- **New tables and columns** can take a moment to show up while PostgREST reloads its schema cache. See [PostgREST not seeing new tables](/kb/postgrest-schema-cache).
- **Types:** generate TypeScript types and pass them to `createClient<Database>()` for typed queries. See [Generating TypeScript types](/docs/api/generating-types).

## Queues from a function

If you've enabled Queues on the project, the admin client can use the `pgmq_public` wrappers, which `service_role` is allowed to execute:

```ts
await admin.schema("pgmq_public").rpc("send", {
  queue_name: "thumbnails",
  message: { recipeId: 812 },
});
```

See [Processing queue messages](/docs/cron-queues/queue-consumers) for the consumer side.

## Storage and Realtime

The same clients work for Storage (`supabase.storage.from("photos")`, subject to Storage policies on the user client) and for sending Realtime broadcasts. See [Uploading files](/docs/storage/uploads) and [Broadcast](/docs/realtime/broadcast).

## Related

- [Row Level Security](/docs/database/row-level-security)
- [service_role client still gets RLS errors](/kb/service-role-rls-errors)
- [Can I connect from serverless and edge functions?](/kb/serverless-edge-connections)
