# Import a Postgres database

Move a Postgres database from another host (Heroku, Render, Railway, Fly, RDS, Supabase, Neon, or your own server) into a new Deplane [database](databases.md). Paste the source's connection URL, check it, and start. For a source that supports it, Deplane keeps the copy in sync while your app still runs on the old database; then you stop writes, press **Finish**, and switch in under a minute.

Open **Databases**, then **Import a database**, or run [`deplane db import`](cli.md#deplane-db-import). The command and its flags are in the [CLI reference](cli.md#deplane-db-import), and every error has a fix in [Troubleshooting and error codes](errors.md#import_check_failed).

An import is for people: CI tokens and AI tools cannot start one, and the [assistant](assistant.md) never takes a database URL or password. The import always creates a **new** database; it never overwrites one you have.

## Two ways to import

Deplane checks the source first and tells you which of the two is possible, with the reasons. The dashboard picks live when it is possible. When the check says copy is possible, it is yours to choose even if live is too.

| | Copy | Live |
|---|---|---|
| How | One consistent snapshot of the source is dumped and loaded into the new database | The same copy, then Deplane keeps following the source's changes until you finish |
| The source needs | Read access to everything | Postgres 10 or newer (13 if it has partitioned tables), `wal_level = logical`, a role that may replicate, ownership of every table, a primary key or replica identity on every table, no large objects, no unlogged tables |
| Changes on the source | None (shared locks on the tables while it dumps) | One publication and one replication slot (see below) |
| Writes after the start | Not copied | Copied until you finish |
| Downtime | From when you stop writes until the copy is done (minutes per GB) | Writes stop, then **Finish**: under a minute |
| Checked by | Exact row count of every table, compared with the new database | The same at the end of the copy, again at Finish with the source quiet, and an unchanged schema |

Copy is the simpler choice if your app can be down for a few minutes. Live is for a database that must keep serving.

## What Deplane changes on your database

- **Copy mode changes nothing.** Deplane reads the source in one snapshot. The dump holds shared locks on the tables while it runs, so schema changes (DDL) on the source wait until it ends.
- **Live mode adds exactly two things:** a publication and a replication slot, both named `deplane_import_` followed by the import's ID. Deplane never alters your tables: if a table has no primary key or replica identity, the check says so and names the fix to run yourself (`ALTER TABLE ... REPLICA IDENTITY FULL`), or you choose copy.
- **Deplane removes both when the import ends:** on finish, on cancel, on failure, and after 14 days of syncing. Until then the slot makes the source keep its change log (WAL), which uses disk on the source. See [Before you start](#before-you-start).
- **Not copied:** roles, grants and ownership (everything in the new database is owned by `app`, like every Deplane database), other databases on the server, and server-wide objects. Extensions Deplane's Postgres does not have are listed by the check; the ones nothing depends on are left out.

**Your source password.** The URL is sent once, over HTTPS, in the body of a request: never in an address, a log line or an audit entry. Deplane stores it encrypted for the length of the import and erases it when the import is done, cancelled, failed or expired. In a **live** import the new database must also hold the password to follow the source: it is stored in the new database (its subscription) until you finish or cancel, and your team can read it there, since it is your own password. It is also in that database's [backups](databases.md#backups-and-restore) for their retention period (7 days on the Beta plan), even after the import ends. If that matters, change the source's password for that role when the import is over, or use a role made for the import and drop it afterwards.

## Before you start

**Let Deplane connect.** If the source only accepts connections from listed addresses, allow the addresses the import page shows ("If your database only accepts listed addresses, allow these"). Deplane connects over TLS by default (`sslmode=require`); the check warns when the connection is not encrypted. Hosts on private ranges and names that resolve to them are not reachable.

**For a live import, on the source:**

1. `wal_level = logical` (this needs a restart on most servers).
2. A role with the `REPLICATION` attribute (or your provider's equivalent, such as `rds_replication`), and ownership of the tables. The publication and the slot belong to the role you imported with.
3. `CREATE` privilege on the database, for the publication.
4. One free replication slot and one free WAL sender (`max_replication_slots`, `max_wal_senders`).
5. A `pg_hba.conf` line that lets the role connect to the database from the addresses above. Logical replication connections match the database's own line, so no `replication` line is needed (that keyword is for physical replication only).
6. Consider `max_slot_wal_keep_size` (Postgres 13 or newer). A slot that is not read makes the source keep every change since it was made, which fills its disk. The setting is **server-wide**: it applies to every slot on the server, including your own replicas and change-data-capture consumers, and a slot that falls behind the limit is invalidated, which breaks it for good. If you set it, choose a value above what your existing replicas and consumers may need to fall behind, and below your free disk. If you are not sure, leave it: Deplane stops the import and removes its slot if the slot holds more than 5 GiB of the source's WAL.

**Both modes:** the source must be Postgres 9.2 to 17 for copy (10 to 17 for live), and at most 5 GB (see [Limits](#limits)). The **Leave out schemas** field is optional. Empty, Deplane leaves out the internal schemas of providers it recognises (the report lists them); any list you type **replaces** those defaults. Anything in the schemas you import that uses a schema you leave out cannot be copied: a foreign key to one of its tables, a row-level security policy or column default that calls one of its functions, a view that reads from it, or a trigger that runs one of its functions. The check refuses both modes and names those objects: include that schema, or drop or rewrite them on the source first.

## Finish a live import

A live import goes through **Checking**, **Copying** and then **Syncing**. While syncing, the page shows how far behind the new database is (it should read "Caught up" or a few KB) and when the last change was applied. Nothing changes for your app: it keeps using the old database.

When you are ready to switch:

1. **Stop writes to the source:** scale your app to 0 or turn on maintenance mode.
2. Press **Finish import** (or run `deplane db import finish`) and confirm that writes are stopped.
3. Deplane copies the last changes and the position of every sequence (logical replication does not copy them), compares the row count of every table with the source, checks that the schema did not change, and removes the publication and slot.
4. When the import shows **Done**, [attach the new database to your app](databases.md#create-and-attach) from its page on **Databases**. The app gets `DATABASE_URL` and rolls out a new release without downtime. Then scale the app back up.

If the schema changed, the new database had not caught up, or a table's count differs, Finish changes nothing and the import goes back to **Syncing** with the reason, for example that a table has more rows on the source because something is still writing. Fix that and finish again. If the schema changed, cancel and import again after the change: live imports do not copy schema changes, and the page warns as soon as it sees one.

A live import that stays in **Syncing** for 14 days is cancelled by Deplane (the page warns from day 12). Until the import is done or cancelled, the new database cannot be attached to an app, deleted or restored. Even after a live import ends, the new database cannot be restored to a moment before it ended.

## If something goes wrong

- **Cancel** is available in every state before **Done**. Copying stops and Deplane removes the publication and slot from the source. The new database stays: a copy, or a live import cancelled before its copy finished, is undone, so the database is left empty; a live import cancelled while it keeps the database in sync leaves the data copied so far. Delete it from **Databases** if you do not need it. A failed import leaves the new database the same way.
- A **failed** import ends the same way, with the reason on the page. The error codes list the fixes: [import_failed](errors.md#import_failed), [import_check_failed](errors.md#import_check_failed), [import_finish_refused](errors.md#import_finish_refused).
- **If Deplane cannot reach the source to clean up**, the import still ends, and the page shows two SQL lines in a box with a **Copy SQL** button. Run them on the source as the role you imported with (it needs `REPLICATION` and must own the publication), or as a superuser:

  ```sql
  SELECT pg_drop_replication_slot('deplane_import_<id>');
  DROP PUBLICATION IF EXISTS deplane_import_<id>;
  ```

  (the page shows them with your import's ID filled in). Do this soon: until the slot is dropped, the source keeps its change log and its disk keeps filling. (If `max_slot_wal_keep_size` is set, the source invalidates the slot once it falls too far behind, but you still run the lines to remove it.)
- A **check** that fails or expires leaves nothing behind. A checked import that you do not start within an hour expires, and its stored password is erased.

## Provider notes

**Heroku Postgres.** Copy only: Heroku does not give you the replication rights live mode needs. Use the app's `DATABASE_URL` (or the URL from `heroku pg:credentials:url`). Stop writes (maintenance mode) for the length of the copy, then switch your app. Plan on a few minutes per GB.

**Amazon RDS and Aurora.** For live, set the parameter `rds.logical_replication = 1` in the instance's (or the Aurora cluster's) parameter group and **reboot** the instance; this sets `wal_level = logical`. Grant the role `rds_replication` (`GRANT rds_replication TO your_role;`) and make sure it owns the tables. RDS leaves `max_slot_wal_keep_size` at unlimited (`-1`), so an unread slot can fill the instance's storage. The setting applies to every slot on the instance, so pick a value above what your existing replicas and consumers need, or rely on Deplane's 5 GiB guard, and watch free storage. In the security group, allow the addresses from the import page.

**Supabase.** The direct database host (`db.<project>.supabase.co`) is IPv6-only unless you buy the IPv4 add-on, and Deplane connects over IPv4. For a **copy**, use the **session pooler** connection string from the project's Connect panel (port 5432 on `...pooler.supabase.com`, with the user `postgres.<project>`). Live replication needs the **direct connection**, so it only works with the IPv4 add-on. Deplane leaves Supabase's internal schemas (`auth`, `storage`, `realtime` and the like) out by default (not just suggested); your own tables in `public` are copied. Supabase Auth users and Storage files are not part of this import. **Objects that use those schemas:** a table such as `public.profiles` with `id REFERENCES auth.users` cannot be restored without `auth.users`, and a policy or default that calls `auth.uid()` cannot be restored without `auth.uid()`. The check names every such object it finds (foreign keys, policies, defaults, views and triggers). Either drop or rewrite them on the source before importing (a policy on `auth.uid()` needs a replacement anyway: Supabase Auth does not come with the import), or type the schemas to leave out yourself, without `auth`. Deplane then copies `auth` into the new database as ordinary tables and functions, which can still fail on other Supabase-specific objects: the import then fails with the restore's error.

**Neon.** For live, turn on logical replication in the project's settings (this restarts the compute) and use the **direct** endpoint, not the pooled one (the host without `-pooler`). Copy works with either, but use the direct one anyway.

**Render, Railway and Fly Postgres.** Use the public connection URL: Render's *External Database URL* (and add Deplane's addresses under the database's inbound rules), Railway's public network URL (`...proxy.rlwy.net`), or, for Fly, a public address you have set up for the database. Copy works with any of them once Deplane can connect. Live needs `wal_level = logical` and a role with `REPLICATION` (or superuser); where the provider does not let you change that, use copy.

**Self-hosted Postgres.** Set `wal_level = logical` and restart, give the import role `REPLICATION` and `CREATE` on the database (`CREATE ROLE import LOGIN REPLICATION PASSWORD '...'; GRANT CREATE ON DATABASE app TO import;`), and make sure it owns the tables (or is a member of the owning role). Add a `pg_hba.conf` line for each address on the import page, using `hostssl`. Logical replication connects to the database itself, so the database line is enough (a `replication` line is for physical replication only):

```
hostssl  app  import  203.0.113.10/32  scram-sha-256
```

Use the addresses the import page shows instead of this example one. Open the port in your firewall for those addresses only, consider `max_slot_wal_keep_size` (it is server-wide, see above), and remove the role when the import is done.

## Limits

| Limit | Value |
|---|---|
| Source size | 5 GB (the database's size as Postgres reports it); larger sources are refused with [source_too_large](errors.md#source_too_large) |
| Imports at a time | One active import per team, from the check until it is done, cancelled or failed ([import_active](errors.md#import_active)) |
| Checks | 20 a day per team ([too_many_import_checks](errors.md#too_many_import_checks)) |
| A checked import | Expires after 1 hour if you do not start it |
| The copy step | At most 4 hours |
| Syncing | At most 14 days, then Deplane cancels the import; warned from day 12 |
| Source versions | Postgres 9.2 to 17 for copy, 10 to 17 for live |
| Who can import | People signed in to the dashboard or the CLI; not CI tokens or AI tools |

See also [Limits](limits.md#databases).
