Last updated: 6 November 2026
Supabase vs Postgres is a strange matchup, because Supabase is Postgres. What you're really deciding is whether to pay for the pieces Supabase puts around it: sign-in, file storage, live updates, an auto-generated API and backups. For some apps that bundle is the best $25 you'll spend. For others it's $25 a month for a database plus features you never call. This guide shows what each piece costs on its own, where Supabase clearly wins, and how to keep your Supabase-style security rules working on plain Postgres if you move.
Try it on Hubfly space: a managed PostgreSQL 18 database starts at about $2 a month.
Prices are as of 6 November 2026, in US dollars.
What Supabase adds on top of Postgres
Every Supabase project is a regular Postgres database. Around it, Supabase runs several services and gives them one dashboard, one client library and one bill.
| Supabase piece | What it does for you | Plain-Postgres replacement |
|---|---|---|
| Auth | Sign-up, login, OAuth, magic links; puts the user ID in every request | An auth library in your API (e.g. Better Auth or Auth.js), or a hosted service like Clerk |
| Auto-generated API | Query tables straight from the browser, guarded by row-level security | Your own small API (Express, FastAPI, Go…) |
| Row-level security helpers |
auth.uid() inside policies |
The same policies, plus a 10-line shim (below) |
| Storage | Files in buckets with access rules | Any S3-compatible bucket |
| Realtime | Live updates over WebSockets | A Pusher-compatible server, or Postgres LISTEN/NOTIFY in your API |
| Edge Functions | Server code next to the database | Code in your API container |
| Backups | Daily backups on paid plans, optional point-in-time restore | Managed backups from your provider, or scheduled pg_dump
|
The pattern is clear: Supabase's extras replace code you would otherwise write and run. That's the trade. You pay less for plain Postgres, and you write (or wire up) more yourself.
Supabase pricing in 2026
From Supabase's pricing page:
- Free: $0. 500 MB database, 50,000 monthly active users, 1 GB of files, 5 GB of egress, 2 active projects. No automatic backups. Projects are paused after 1 week of inactivity.
- Pro: from $25/month. Includes $10 of compute credit, which covers one Micro instance (shared CPU, 1 GB RAM). 8 GB of disk, 100,000 monthly active users, 100 GB of files, 250 GB of egress, daily backups kept 7 days.
- Team: from $599/month.
Pro overage rates: disk $0.125/GB, users $0.00325 each past 100,000, files $0.0213/GB, egress $0.09/GB ($0.03 cached). Point-in-time restore is an add-on at $100/month for 7 days, and a custom domain is $10/month. Bigger compute costs extra: Small (2 GB) about $15, Medium (4 GB) about $60.
Plain Postgres pricing in 2026
Here is what a database alone costs, roughly matched to Supabase Pro's 1 GB Micro instance.
| Provider | Smallest sensible plan | Monthly price | Backups |
|---|---|---|---|
| Supabase Pro | Micro, 1 GB RAM, 8 GB disk | $25 (includes auth, files, realtime) | Daily, 7 days |
| Neon Launch | Usage-based, billed per compute-hour | Neon's own example: about $15 for 140 compute-hours and 1 GB | Instant restore, $0.20/GB-month |
| Render | 1 GB RAM | $19 + storage at $0.30/GB | Point-in-time restore, 3 days (Hobby) or 7 days (Pro) |
| DigitalOcean | 1 vCPU, 1 GiB | $15.15 with 10 GiB | Daily backups + 7-day point-in-time restore |
| Hubfly space | Small, 0.5 vCPU, 1 GiB, 10 GiB | $3.92 | No automatic backups yet (coming soon) |
Two things stand out. Supabase Pro is a fair price once you count everything in it. And if you only need the database, you can pay a fraction of that.
Neon deserves a special mention: every plan now includes managed Better Auth, a Data API and Realtime at no separate charge, with auth for up to 60,000 monthly active users per project on Free and 1 million on paid plans. If what you want is "Postgres plus login", it's a strong middle ground.
Supabase vs Postgres: same app, two bills
Take a typical small SaaS: a 2 GB database, 10,000 monthly active users, 20 GB of uploaded files and live updates in the UI.
On Supabase Pro: $25.00 a month. Everything fits inside Pro's included amounts: 2 GB of the 8 GB disk, 10,000 of 100,000 users, 20 GB of 100 GB of files. The free plan won't do, because the database alone is over its 500 MB limit.
On plain Postgres plus your own pieces (Hubfly space): $9.09 a month.
| Piece | Size | Monthly |
|---|---|---|
| Hubfly space managed PostgreSQL | Small: 0.5 vCPU, 1 GiB, 10 GiB | $3.92 |
| Hubfly space Cell container (runs your auth library and API) | 0.5 vCPU, 1 GB, 10 GB disk, always on | $3.03 |
| Hubfly space Storage | 20 GB of files | $0.22 |
| Managed Realtime | Nano | $1.92 |
| Auth | Open-source library inside your API | $0 |
| Total | $9.09 |
How each number is worked out (730 hours a month; CPU $0.004 per vCPU-hour, memory $0.002 per GB-hour, storage $0.00001536 per GB-hour):
- Database: (0.5 × $0.004 + 1 × $0.002) = $0.004/h, plus the 30% management fee ($0.0012/h), plus 11 GiB of disk (10 GiB data + 1 GiB system) × $0.00001536 = $0.000169/h. Total $0.005369/h × 730 = $3.92.
- API container: (0.5 × $0.004 + 1 × $0.002) × 730 = $2.92, plus 10 GB × $0.00001536 × 730 = $0.11. Total $3.03.
- Files: 20 GB × $0.00001536 × 730 = $0.22.
- Realtime: the Nano size is $1.92, with no per-message fee.
Network transfer isn't in either total. Supabase Pro includes 250 GB of egress; check your provider's bandwidth terms before comparing heavy-download apps.
When the math flips
The gap closes fast if you'd buy auth as a hosted service. Say the app grows to 150,000 monthly active users:
- Supabase Pro: $25 + 50,000 extra users × $0.00325 = $187.50.
- Clerk Pro (billed monthly) for sign-in alone, assuming all 150,000 count as Clerk's monthly retained users: $25 + 50,000 users × $0.02 + 50,000 users × $0.018 = $1,925.
- An open-source auth library in your own API: $0 extra, but you own password resets, OAuth setup and security updates.
So Supabase's included users are a big deal if your alternative is a hosted auth service. They matter much less if you're happy running a library yourself.
When Supabase is the better choice
Be honest about what you'd rebuild. Pick Supabase when:
- You'd otherwise buy hosted auth. 100,000 monthly active users are included in Pro.
- Your app is mostly frontend. The auto-generated API plus row-level security means many apps need no backend server at all.
- You want backups handled. Pro keeps 7 days of daily backups without you doing anything, and point-in-time restore is available as a $100/month add-on.
- You're prototyping. The free plan gets you a database, login and file uploads in minutes. Just remember free projects pause after a week without activity.
- You value one dashboard. One place for users, tables, files, logs and keys saves real time on a small team.
When plain Postgres is the better choice
- You already have a backend. If your API handles auth and business logic, Supabase's auto-generated API goes unused.
- You only need a database. A 1 GB managed Postgres costs $3.92 to about $20 elsewhere versus $25.
- You want to own your auth data. Users live in your own tables, with no provider-specific schema.
The honest downside on Hubfly space today: managed Postgres runs on a single node, with no replicas, no high availability and no point-in-time restore, and automatic backups are still coming. Until they arrive, run your own pg_dump on a schedule from any machine or job runner you already have, and keep the files somewhere else (the dump and restore commands are in how to upgrade Postgres to a new major version in Docker). If you need automatic backups or failover today, Supabase Pro, DigitalOcean or Render are the safer picks.
Moving from Supabase to plain Postgres: the part guides skip
Most migration guides say "run pg_dump, then pg_restore". On a typical Supabase schema, that fails, because your tables and policies point at things that only exist inside Supabase: the auth schema, the auth.uid() function and roles such as authenticated.
Here's a typical table from a Supabase app:
create table public.todos (
id bigint generated by default as identity primary key,
user_id uuid not null default auth.uid() references auth.users(id) on delete cascade,
title text not null,
done boolean not null default false
);
alter table public.todos enable row level security;
create policy "own todos read" on public.todos
for select to authenticated using ((select auth.uid()) = user_id);
create policy "own todos write" on public.todos
for insert to authenticated with check ((select auth.uid()) = user_id);
Dump only your own schema and restore it into plain PostgreSQL, and you get this:
pg_restore: error: could not execute query: ERROR: schema "public" already exists
pg_restore: error: could not execute query: ERROR: schema "auth" does not exist
LINE 3: user_id uuid DEFAULT auth.uid() NOT NULL,
pg_restore: error: could not execute query: ERROR: relation "public.todos" does not exist
...
pg_restore: warning: errors ignored on restore: 12
The dangerous part: pg_restore keeps going. It finishes with a warning and exit status 1, and your todos table simply isn't there. Adding --no-owner --no-acl doesn't help; the table still needs auth.uid().
The fix is to recreate the small part of Supabase your schema depends on, before you restore.
Step 1: Dump your own tables
Supabase runs Postgres 17, and pg_dump must be the same major version as the server or newer, so use version 17 or newer. The flags below are the same in every recent version. Copy a direct or session-mode connection string from your Supabase project's Connect dialog.
pg_dump "$SUPABASE_DB_URL" --format=custom --schema=public --file=public.dump
--schema=public leaves out Supabase's own schemas (auth, storage, realtime and others). Your users are in auth.users, so export the columns you need separately:
psql "$SUPABASE_DB_URL" -c "\copy (select id, email from auth.users) to 'users.csv' with csv header"
Step 2: Add the shim to the new database
This recreates the three roles, a minimal auth.users table and auth.uid(). It reads the user ID the same way Supabase's own helpers do: first request.jwt.claim.sub, then the sub field of request.jwt.claims (Supabase's example). Save it as shim.sql:
-- Make plain Postgres understand Supabase-style policies
do $$ begin
if not exists (select from pg_roles where rolname='anon') then create role anon nologin; end if;
if not exists (select from pg_roles where rolname='authenticated') then create role authenticated nologin; end if;
if not exists (select from pg_roles where rolname='service_role') then create role service_role nologin; end if;
end $$;
create schema if not exists auth;
create table if not exists auth.users (id uuid primary key, email text);
create or replace function auth.uid() returns uuid language sql stable as $$
select coalesce(
nullif(current_setting('request.jwt.claim.sub', true), ''),
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
)::uuid
$$;
grant usage on schema auth to anon, authenticated, service_role;
grant execute on function auth.uid() to anon, authenticated, service_role;
It works with a regular admin user that can create roles; no superuser needed. That matters on managed databases, where you never get superuser. (It's also why service_role here has no BYPASSRLS: a role that doesn't have it can't give it, and admin users on managed databases normally don't have it.)
The nullif(…, '') is not decoration. Once a setting has been used on a connection, Postgres returns an empty string for it afterwards, not NULL. Without nullif, the next request on that pooled connection fails with invalid input syntax for type json.
Step 3: Load users, then restore
psql "$NEW_DB_URL" -v ON_ERROR_STOP=1 -f shim.sql
psql "$NEW_DB_URL" -c "\copy auth.users (id, email) from 'users.csv' with csv header"
pg_restore --list public.dump | grep -v -E 'SCHEMA - public|COMMENT - SCHEMA public|DEFAULT ACL' > restore.list
pg_restore --no-owner --exit-on-error --use-list=restore.list --dbname="$NEW_DB_URL" public.dump
The list file skips two kinds of entries. CREATE SCHEMA public already exists and would otherwise be the first error. DEFAULT ACL lines are Supabase's default grants for its own roles (for example ALTER DEFAULT PRIVILEGES FOR ROLE postgres …); a non-superuser can't set them for another role, and the restore stops with permission denied to change default privileges.
Before restoring, list the grants the restore will run. Every role named there must exist in the new database:
pg_restore --file=- --use-list=restore.list public.dump | grep -E '^(GRANT|REVOKE)'
If the restore still stops with role "…" does not exist, create that role with create role <name> nologin; and run the restore again on a fresh database. Don't delete the table's ACL line instead: it holds all of that table's grants, including the one to authenticated your policies need. For any other Supabase-only object the error names, remove its line from restore.list. --exit-on-error makes the restore stop at the first real problem instead of skipping tables. Keep the grants (no --no-acl): your policies need the authenticated role to have its table permissions.
If a column default calls extensions.uuid_generate_v4(), create that extension in a schema with the same name before restoring: create schema extensions; create extension "uuid-ossp" schema extensions;.
Step 4: Run queries as the signed-in user
Your policies now work, but only if each request tells Postgres who the user is. Create a login role for your app that does not own the tables, and let it act as authenticated:
create role shop_api login password 'a-long-random-password';
grant authenticated to shop_api;
Then wrap each request in a transaction that switches role and sets the user ID. Here it is for Node.js 22 with pg 8:
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Run queries as one signed-in user, so Supabase-style RLS policies apply.
async function asUser(userId, fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SET LOCAL ROLE authenticated');
await client.query("SELECT set_config('request.jwt.claims', $1, true)",
[JSON.stringify({ sub: userId, role: 'authenticated' })]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Usage: const { rows } = await asUser(session.userId, c => c.query('SELECT * FROM todos'));
SET LOCAL and the true in set_config scope both to the transaction, so the next request on the same pooled connection starts clean. With this in place, user A sees only A's rows, and when user B tries to insert a row for user A, Postgres refuses with new row violates row-level security policy for table "todos".
What can go wrong
-
Every user sees every row. Your app connects as the table owner. Owners skip row-level security by default, so a policy that works on Supabase silently does nothing. Connect as a separate role like
shop_api, or runalter table todos force row level security;. -
Every query returns zero rows. The role switch worked but the user ID didn't arrive, so
auth.uid()is NULL. Check thatset_configruns inside the same transaction as the query. -
pg_restore"finished" but a table is missing. You restored without--exit-on-error. Look forerrors ignored on restorein the output, drop the new database (not your Supabase one) and restore again with the flag. -
server version mismatchfrompg_dump. Your localpg_dumpis older than the server. Install the PostgreSQL 17 or 18 client tools. -
Uploaded files are gone. Supabase backups don't include Storage objects, and neither does
pg_dump: the database only holds their metadata. Copy the files with any S3 tool before you switch off the old project. -
Everyone has to log in again. Sessions and password hashes belong to Supabase Auth. Plan the move of logins separately; exporting
idandemailkeeps your foreign keys intact, not your users' passwords.
FAQ
Is Supabase just Postgres?
The database is regular Postgres, and you can connect to it with any Postgres client or ORM. Supabase adds auth, storage, realtime, an auto-generated API and backups around it, plus a few schemas and roles of its own.
Do I need Supabase or just Postgres?
If you have your own backend and only need a database, plain Postgres is cheaper. If you'd otherwise buy hosted auth, or your app talks to the database straight from the browser, Supabase saves you real work.
How much does Supabase cost per month?
Free is $0 with a 500 MB database and pausing after a week of inactivity. Pro starts at $25 a month with 8 GB of disk, 100,000 monthly active users and 250 GB of egress. Bigger compute, extra users and point-in-time restore cost extra.
Can I move my Supabase database to another Postgres host?
Yes. Dump your own schema with pg_dump, recreate auth.uid() and the Supabase roles on the new database, then restore with --exit-on-error. Files in Supabase Storage and user passwords need their own migration.
Will my RLS policies work outside Supabase?
Yes, once auth.uid() exists and each request sets the user ID in a transaction, as in Step 4. Policies are plain Postgres; only the helper function is Supabase-specific.
Do Supabase backups include my uploaded files?
No. Supabase's backups cover the database only, which stores metadata about your files, not the files themselves.
What's the cheapest way to get Postgres plus login?
Run an open-source auth library in your own API next to a small managed database, or use Neon, whose plans include managed Better Auth at no separate charge. For a broader price list, see the cheapest database for a small app in 2026.
Go further on Hubfly space
- Create the database in a minute or two with a managed PostgreSQL database for about $2 a month.
- Find slow queries after the move with Query insights: how to find and fix slow PostgreSQL queries.
- Store user uploads in Hubfly space Storage, an S3-compatible bucket at about $0.011 per GB a month.
Keep your Postgres, drop the bill you don't use. Create a managed PostgreSQL 18 database and pay for the size you pick. Try it on Hubfly space
By Octave · drafted with AI, reviewed by Octave · Last updated 6 November 2026
Top comments (0)