Postgres
Keep responses in Postgres instead of a SQLite file: Supabase, Neon, RDS, Railway or your own server. Set one connection string; Open Form creates its tables.
SQLite needs a persistent disk. When there isn't one (Vercel and other serverless hosts), or you want responses in a database you already run, point Open Form at Postgres with one environment variable:
OPEN_FORM_DATABASE_URL='postgres://user:password@host:5432/dbname'Percent-encode special characters in the user name and password, such as #
as %23 and @ as %40. Open Form refuses a connection string it can't read
rather than guess which part is the host.
open-form dev, start, list, responses export, delete, the
Vercel function and the Next.js route
all use it. Nothing else changes: Studio,
filters, exports, webhooks and response numbers work the same as on SQLite.
Install the driver
Postgres support uses pg, which isn't installed
until you need it.
npm install pgopen-form init --database postgres adds it for you.
Set the connection string
Put it in .env for local work (the .gitignore from open-form init already
ignores it), and in your host's environment settings in production. The
environment wins over .env.
Never write the connection string into open-form.config.ts: that file is
committed. To reuse a variable your app already has, read it from the
environment instead, and open-form check fails when the
config holds a URL with a password.
export default defineConfig({
storage: { url: process.env.DATABASE_URL },
});OPEN_FORM_DATABASE_URL wins over storage.url. Open Form doesn't read
DATABASE_URL on its own, because in many apps it points at the app's own
database. The one exception is the Vercel function:
there, when nothing else picks a database, it uses the POSTGRES_URL or
DATABASE_URL that a Vercel Marketplace database sets.
Check it
npx open-form db status ┃ Storage Postgres (from OPEN_FORM_DATABASE_URL)
┃ Database postgres://forms:***@db.example.com:5432/app?sslmode=verify-full
┃ Status ✔ connected PostgreSQL 16.4 · schema open_form · migration 1
┃ Responses contact 12 · meetup 87The first connection creates the tables. See open-form db status.
Where the tables go
Open Form keeps its tables in their own schema, open_form, away from your
app's tables in public. It creates the schema on first start, then the same
tables as SQLite: submissions, form_versions, form_counters,
form_overrides, rate_limits, revoked_sessions and meta, plus
submission_files, which is reserved for file uploads and unused today. Answers are stored as json, so they come back with their fields in
the order the respondent's form sent them, and you can query them:
SELECT number, data->>'email' AS email, created_at
FROM open_form.submissions
WHERE form_id = 'meetup'
ORDER BY number DESC;There's no separate migrate command: whatever opens the database applies any
migration that's missing. That's the dev server, open-form start and the
Next.js route on their first request, and
db status, list, responses export and delete. When many
open it at once (serverless cold starts), a Postgres advisory lock lets one run
the migrations while the others wait; once the database is up to date, opening
it takes no lock.
To give Open Form a role of its own, the role needs to create the open_form
schema on first start, or own one you create for it:
CREATE ROLE open_form_app LOGIN PASSWORD '…';
CREATE SCHEMA open_form AUTHORIZATION open_form_app;Encryption
Connections to any host other than localhost verify the server's
certificate. A connection string without sslmode gets
sslmode=verify-full, so answers are never sent in the clear by accident.
sslmode=verify-full: encrypt and verify. Write this one. Works with providers whose certificates come from a public authority, like Neon.sslrootcert=./ca.crt: verify against your provider's own certificate authority, for providers that sign with one, like Supabase.pgreads the file relative to the directory the process runs in, not the project root, so use an absolute path if you start Open Form from another directory. On Vercel, ship the file with the function as shown in Vercel.sslmode=no-verify: encrypt without checking who answers. Use it only on a network you trust, or for a test against a provider whose certificate can't be verified, like Railway's Postgres, which signs its own.sslmode=disable: no encryption. Open Form logs a warning when the host isn't local.
sslmode=require also verifies in pg, unless the URL adds
uselibpqcompat=true; verify-full means the same thing either way.
Serverless and pooling
Each server or function keeps a small pool of connections (pg's default of
10). On serverless hosts, many short-lived functions can run out of
connections, so use your provider's pooled connection string: Neon's -pooler
host, or Supabase's transaction pooler on port 6543. Open Form doesn't use
named prepared statements, so transaction pooling works.
Without a writable disk, also set OPEN_FORM_SECRET. See
Vercel.
Deleting responses
A deleted response is gone from every query at once, but Postgres keeps the old
row on disk until VACUUM reclaims it (autovacuum does this on its own).
Backups and point-in-time recovery keep copies for as long as your provider
retains them. SQLite, by contrast, overwrites a deleted response immediately.
Moving from SQLite
Setting OPEN_FORM_DATABASE_URL starts with an empty Postgres database:
responses already in .open-form/open-form.db stay in that file until you copy
them. open-form db copy moves them across with their
response numbers and Studio's settings:
npx open-form db copy --dry-run
npx open-form db copyRun it once the connection string is set and before the form takes responses
on Postgres: it copies only into an empty database, so a second run, or a
Postgres that already has responses, writes nothing. On a deployed app, point
your local OPEN_FORM_DATABASE_URL at the production database and run it from
the workspace that holds the SQLite file.
The two databases stay separate afterwards. If you switch back to SQLite,
Studio shows the responses in .open-form/open-form.db again, and the ones
collected in Postgres are only visible while OPEN_FORM_DATABASE_URL points
there. To read the SQLite file for one command without switching, leave the
variable empty (this works as long as storage.url isn't set in
open-form.config.ts):
OPEN_FORM_DATABASE_URL= npx open-form responses export meetup -o meetup-responses.json