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.
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.
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
- You need realtime in a synchronous program. Calling client.channel() on the sync client raises NotImplementedError with the message that the feature is available in the async client only, and the same applies to get_channels, remove_channel, and remove_all_channels. This is not in the README
- You need transactions, CTEs, window functions, or anything else PostgREST cannot express in a URL. There is no BEGIN and COMMIT here. Multi-statement atomicity has to move into a Postgres function that you call through rpc(), or you connect directly with psycopg or SQLAlchemy instead
- Your dependency tree is crowded. 2.31.0 pins postgrest, supabase-auth, storage3, supabase-functions, and realtime to exactly 2.31.0, and constrains httpx to at least 0.26 and below 0.29, so a single conflict with another HTTP library means you cannot install it at all and cannot patch around it by upgrading one sibling
- You are doing bulk or analytical work. Every query is an HTTP round trip with JSON serialisation on both ends, so a loop of ten thousand small writes is ten thousand requests where a driver would use one COPY
- You want documentation in the repository. The client README is a page of snippets and the root README is contributor setup; the actual reference lives on supabase.com, is generated, and covers less than the JavaScript equivalent
- You want a stable long-term contract. The auth dependency was renamed from gotrue to supabase-auth and functions from supafunc to supabase-functions during the 2.x line, and a 3.0.0a1 prerelease has been on PyPI since April 2026
- You do not want vendor lock-in. This talks to Supabase and nothing else, and the query builder is not portable to any other backend
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.countrange() 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 Nonesingle() 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
| Package | Registry | Pick it when |
|---|---|---|
| postgrest | PyPI | You only want the database query builder and none of the auth, storage, functions, or realtime that the metapackage drags in |
| psycopg | PyPI | You need real SQL: transactions, COPY, LISTEN and NOTIFY, and a connection you control, against the Postgres connection string your Supabase project already gives you |
| sqlalchemy | PyPI | You want models, migrations through Alembic, and a query layer that survives moving off Supabase later |
| asyncpg | PyPI | You 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 |