mrkeyoor.com_
Fri 07 Aug 20:52 UTC
PyPIDataupdated 07 Aug 2026

supabase

supabase is the official Python client for Supabase, and it is really five clients behind one object. create_client(url, key) returns something with .table() for database queries, .auth for sign-up and sign-in, .storage for file buckets, .functions for edge functions, and .channel() for realtime subscriptions. Nothing here speaks the Postgres wire protocol. Every database call becomes an HTTP request to PostgREST, so supabase.table('countries').select('*').eq('code', 'IL').execute() is a GET with query parameters that Postgres turns back into SQL on the other end, and your row-level security policies decide what comes back. That design is what makes the same library usable from a script, a Lambda, and a Django view without connection pooling, and it is also why transactions and joins that PostgREST cannot express are not available to you. The package on PyPI is a thin metapackage: it pins five sibling packages from the same monorepo, postgrest, supabase-auth, storage3, supabase-functions, and realtime, to the exact same version number. Both a synchronous and an async client are exported.

Verdict

If your project is already on Supabase, this is the sensible default and covers auth, storage, and functions in one dependency. Treat the database side as a REST convenience rather than a database driver: the moment you need transactions, bulk writes, or realtime from synchronous code, connect to Postgres directly or move to the async client.

API stability4/5create_client and the PostgREST-style chain of table, select, filter, and execute have held steady across the whole 2.x line, and the sync and async surfaces mirror each other. Points off because two dependencies were renamed mid-major, gotrue to supabase-auth and supafunc to supabase-functions, and a 3.0.0a1 prerelease has been sitting on PyPI since April 2026 with no stated migration path.
Docs3/5The client README gives you a working snippet for every common operation and does flag the explicit sign_out shutdown requirement, but the real reference is a generated site at supabase.com that trails the JavaScript version, the root README is contributor setup, and significant behaviour is undocumented, including that realtime raises NotImplementedError on the sync client.
Maintenance5/5Pushed 7 August 2026, releases roughly monthly through the 2.2x and 2.3x series, 14 open issues and 19 open pull requests against 2,553 stars, and it is maintained by Supabase themselves inside a uv workspace monorepo with per-package test suites run against real Docker containers.
Ecosystem4/5Around 8.8M weekly downloads and the only official Python path to Supabase, with the six monorepo packages usable on their own. It trails supabase-js badly on community extensions, framework integrations, and worked examples, so answers to unusual questions often exist only in JavaScript.

Use it if

  • You are already on Supabase and want auth, Postgres queries, file storage, and edge functions from one dependency with one set of credentials
  • You are writing something short-lived, such as a Lambda, a Cloud Run handler, or a cron script, where an HTTP call per query avoids the connection pooling problem a real Postgres driver creates
  • You want row-level security enforced for you: sign a user in and the client attaches their token to subsequent PostgREST calls, so policies apply without you threading a session through your query layer
  • You need file uploads with signed URLs and public URLs alongside your data, and building that on S3 plus a driver is more work than it is worth
  • You are prototyping and want an async client that mirrors the sync one, so moving a script into FastAPI is mostly adding await
Skip it if

Setup reality

pip install supabase brings six packages, not one, and every sibling is pinned to the same exact version, so pip will refuse rather than negotiate if anything else in your project wants a different postgrest or a different httpx. The httpx range, at least 0.26 and below 0.29, is the constraint that most often collides with other SDKs in the same environment. Python 3.9 is the floor. Getting a client is two environment variables, SUPABASE_URL and SUPABASE_KEY, but which key you use decides everything: the anon key respects row-level security and is what a user-facing app should hold, while the service role key bypasses RLS entirely and must never reach a browser, a mobile app, or a client-side framework's public bundle. The library does not warn you about this, and there is no client-side flag that distinguishes them. Async uses a different constructor, create_async_client, and every call is awaited, so the two styles do not mix in one client object. Realtime only exists on the async side; the sync client's channel methods raise NotImplementedError. Custom timeouts, a different Postgres schema, and a shared httpx client all go through ClientOptions or AsyncClientOptions passed as the third argument, and the schema you pick has to be on the exposed list in your project settings or PostgREST returns an error that does not mention that. The README's shutdown advice is real and easy to miss: you are told to call client.auth.sign_out() explicitly so the client terminates correctly, because the auth client runs a background token refresh that otherwise keeps the process alive.

Patterns

Create a client and read some rowsbasic-usage

import os
from supabase import create_client, Client

supabase: Client = create_client(
    os.environ['SUPABASE_URL'],
    os.environ['SUPABASE_KEY'],
)

response = supabase.table('countries').select('*').eq('code', 'IL').execute()
print(response.data)

Nothing runs until .execute(). The result is an APIResponse with .data and .count, not a list, so iterating the response object directly does not work. The anon key applies row-level security; the service role key ignores it entirely and belongs only on a server you control.

Build a real queryfilter-order-paginate

res = (
    supabase.table('orders')
    .select('id, total, customer:customers(name)', count='exact')
    .gte('total', 100)
    .in_('status', ['paid', 'shipped'])
    .order('created_at', desc=True)
    .range(0, 24)
    .execute()
)

rows, total = res.data, res.count

range() is inclusive on both ends, so range(0, 24) returns 25 rows, and it maps to a Range header rather than OFFSET. count='exact' makes Postgres do a full count, which is slow on large tables; 'planned' and 'estimated' trade accuracy for speed. The customer:customers(name) syntax is PostgREST embedding and needs a real foreign key.

Fetch exactly one row, or nonesingle-row

row = supabase.table('profiles').select('*').eq('id', uid).single().execute().data

# tolerate a missing row instead of raising
res = supabase.table('profiles').select('*').eq('id', uid).maybe_single().execute()
row = res.data if res else None

single() raises an APIError when the result is not exactly one row, which is the behaviour you want for a primary key lookup. maybe_single() returns None instead of a response when nothing matched, so check the response itself before touching .data.

Write rows backinsert-and-upsert

supabase.table('countries').insert({'name': 'Germany'}).execute()

# many at once
supabase.table('countries').insert([
    {'name': 'France'}, {'name': 'Spain'}
]).execute()

# insert or update on the conflict target
supabase.table('countries').upsert(
    {'code': 'GB', 'capital_city': 'London'},
    on_conflict='code',
).execute()

A list argument is one request, which is the difference between a fast import and a slow loop. upsert needs a unique constraint on the on_conflict column or Postgres rejects it. Inserted rows come back in .data, so an insert of a large batch also transfers the whole batch back unless you narrow what is returned.

Change and remove rows safelyupdate-and-delete

supabase.table('countries').update({'capital_city': 'Jakarta'}).eq('id', 1).execute()

supabase.table('countries').delete().eq('id', 1).execute()

A filter is not enforced by the client. update() or delete() with no filter is a whole-table operation, and the only thing standing between you and that is a row-level security policy, which the service role key ignores. Write the filter first, then the verb.

Do what the query builder cannotcall-a-postgres-function

res = supabase.rpc('transfer_credits', {
    'from_user': sender_id,
    'to_user': recipient_id,
    'amount': 25,
}).execute()

# rpc results can be filtered like a table
supabase.rpc('search_docs', {'q': 'postgres'}).limit(10).execute()

This is the answer to transactions: a plpgsql function runs as a single statement, so everything inside it is atomic. It is also how you get CTEs, window functions, and multi-table writes. The function has to be in an exposed schema and have EXECUTE granted to the anon or authenticated role.

Use it from FastAPI or any asyncio appasync-client

from supabase import acreate_client, AsyncClient

supabase: AsyncClient = await acreate_client(url, key)

res = await supabase.table('countries').select('*').execute()

create_async_client is the same function under a second name. The two clients are separate objects: you cannot await calls on a sync client or call a sync method on an async one. Realtime only exists here, and in a request handler you want one client for the process rather than one per request.

Sign a user in and let policies applyauth-and-rls

session = supabase.auth.sign_up({'email': email, 'password': password})

session = supabase.auth.sign_in_with_password({
    'email': email, 'password': password,
})

# subsequent queries now carry the user's access token
mine = supabase.table('notes').select('*').execute()

The client listens to its own auth events and swaps the Authorization header, so RLS starts applying to the same client object after sign-in. In a web server that makes a shared client dangerous: one user's session leaks into another's queries. Create a per-request client, or pass the user's token explicitly.

Upload, download, and share a filestorage-files

bucket = supabase.storage.from_('photos')

bucket.upload('user1/profile.png', file_bytes,
              {'content-type': 'image/png', 'upsert': 'true'})

data = bucket.download('user1/profile.png')

signed = bucket.create_signed_url('user1/profile.png', 3600)
public = bucket.get_public_url('user1/profile.png')

The method is from_ with a trailing underscore because from is a Python keyword. File options are strings, not booleans, so 'upsert': 'true' rather than True. Uploading to an existing path fails without upsert. get_public_url only resolves for buckets marked public; everything else needs a signed URL.

Invoke an edge functionedge-functions

from supabase import FunctionsHttpError, FunctionsRelayError

try:
    res = supabase.functions.invoke(
        'hello-world',
        invoke_options={'body': {'name': 'Ada'}},
    )
except (FunctionsRelayError, FunctionsHttpError) as exc:
    print(exc.to_dict().get('message'))

Non-2xx responses raise rather than returning a status you can inspect, and the useful detail is behind to_dict(). Both error classes are re-exported from the top-level supabase package, so you do not need to import supabase_functions yourself.

Listen to database changesrealtime-subscription

supabase = await acreate_client(url, key)

def handle(payload):
    print(payload['eventType'], payload['new'])

channel = supabase.channel('room-1')
channel.on_postgres_changes(
    event='*', schema='public', table='messages', callback=handle,
)
await channel.subscribe()

Async only. The same code against a client from create_client raises NotImplementedError with 'This feature isn't available in the sync client'. The table also needs to be added to the supabase_realtime publication in your project before any event arrives, which is a dashboard setting and not a client one.

Change schema, timeouts, and the HTTP clientclient-options

import httpx
from supabase import create_client, ClientOptions

supabase = create_client(url, key, options=ClientOptions(
    schema='billing',
    postgrest_client_timeout=30,
    storage_client_timeout=120,
    auto_refresh_token=False,
    persist_session=False,
    headers={'x-app': 'reporting-job'},
))

# or per call, without a second client
supabase.schema('analytics').table('events').select('*').execute()

A non-public schema has to be on the exposed schemas list in project settings or PostgREST returns an error that does not name the cause. In a short-lived worker set auto_refresh_token=False and persist_session=False, otherwise the background token refresh keeps the process from exiting. For the same reason the README tells you to call supabase.auth.sign_out() in a finally block when a script is done, and the symptom of skipping it is a job that finishes its work but never returns to the shell.

Alternatives

PackageRegistryPick it when
postgrestPyPIYou only want the database query builder and none of the auth, storage, functions, or realtime that the metapackage drags in
psycopgPyPIYou need real SQL: transactions, COPY, LISTEN and NOTIFY, and a connection you control, against the Postgres connection string your Supabase project already gives you
sqlalchemyPyPIYou want models, migrations through Alembic, and a query layer that survives moving off Supabase later
asyncpgPyPIYou are async and throughput matters more than the extras, since a binary-protocol driver beats an HTTP round trip per query by a wide margin