Cross-Database Queries

Reviewed & published by Brayan K

By the end of this lesson you'll be able to query data that lives in a different database — or even a different server or engine — using ordinary SQL. You'll set up a PostgreSQL Foreign Data Wrapper, JOIN a local table to a remote one, and know which tool (FDW, dblink, linked servers, FEDERATED) fits each job — plus the traps that make these queries slow or unsafe.

Part of the free SQL course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn

Real-World Analogy

Imagine your company has filing cabinets in London, New York, and Tokyo. A Foreign Data Wrapper is like installing a dumbwaiter to each remote office: you stay at your London desk, ask for a folder, and the system fetches it for you. You get one drawer that looks local but is really pulling paper across the ocean — which is exactly why what you ask for matters. Request "all invoices since Monday" and only those cross the wire; request "everything" and you wait while the whole archive is shipped.

Our Two Tables: one local, one foreign

customers lives in your local database. page_views lives on a remote analytics server and reaches you as the foreign table remote.page_views. The whole lesson JOINs across these two.

1. PostgreSQL FDW — Set Up a Foreign Server

A Foreign Data Wrapper (FDW) is an extension that teaches PostgreSQL how to read another data source. With postgres_fdw, that source is another PostgreSQL database — and once it's wired up, its tables look and behave like local ones.

Setup is four steps: enable the wrapper, declare the remote server (where), create a user mapping (who connects, with credentials stored once instead of in every query), then import the remote table definitions into a local schema.

-- PostgreSQL postgres_fdw — turn a REMOTE database into LOCAL tables.
-- "FDW" = Foreign Data Wrapper: a translator that lets your local
-- PostgreSQL read another data source as if it were just another table.

-- Step 1: enable the wrapper (one-time, per database)
CREATE EXTENSION IF NOT EXISTS postgres_fdw;

-- Step 2: describe WHERE the remote server lives
CREATE SERVER analytics_srv
    FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host 'analytics.example.com', port '5432', dbname 'analytics');

-- Step 3: say WHO connects (credentials live in a user mapping, not in queries)
CREATE USER MAPPING FOR current_user
    SERVER analytics_srv
    OPTIONS (user 'reporter', password 'secret');

-- Step 4: pull the remote table definitions into a local schema called "remote"
IMPORT FOREIGN SCHEMA public
    LIMIT TO (page_views)              -- only import the tables you need
    FROM SERVER analytics_srv
    INTO remote;

-- After this, remote.page_views is queryable exactly like a local table.

Your Turn: wire up a foreign server

Fill in the three blanks to register a remote server, map your credentials, and declare a single foreign table by hand. The expected answers are in the comments.

-- 🎯 YOUR TURN — fill in the three blanks, then press "Try it Yourself".
-- Goal: register a remote PostgreSQL server and map your credentials.

CREATE EXTENSION IF NOT EXISTS postgres_fdw;

CREATE ___ billing_srv               -- 👉 keyword that registers a remote server
    FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host 'billing.example.com', port '5432', dbname 'billing');

CREATE USER ___ FOR current_user     -- 👉 keyword that ties a login to the server
    SERVER billing_srv
    OPTIONS (user 'reporter', password 'secret');

CREATE FOREIGN ___ remote_invoices ( -- 👉 the object type a foreign column set lives in
    invoice_id INT,
    amount     NUMERIC
) SERVER billing_srv OPTIONS (schema_name 'public', table_name 'invoices');

-- ✅ Expected: 3 statements succeed; remote_invoices is now a foreign table.
--    Blanks are:  SERVER  ,  MAPPING  ,  TABLE

2. JOIN Local and Foreign Tables

This is the payoff: a foreign table behaves like any other table, so you can JOIN it to local data in one ordinary query. Below, the local customers table joins the foreign remote.page_views table to count views per customer.

-- A foreign table behaves like any other table — you can SELECT, JOIN,
-- filter and aggregate it. Here we JOIN a LOCAL table (customers) to a
-- FOREIGN table (remote.page_views) in a single, ordinary query.

SELECT
    c.name,
    COUNT(*)            AS views,
    MAX(v.viewed_at)    AS last_seen
FROM customers c                       -- local table
JOIN remote.page_views v               -- foreign table (lives on another server)
    ON v.customer_id = c.id
WHERE v.viewed_at >= DATE '2026-06-08' -- filter pushes to the remote side
GROUP BY c.name
ORDER BY views DESC;

Your Turn: join across the two tables

Two blanks — the keyword that combines tables, and the local column that lines up with v.customer_id.

-- 🎯 YOUR TURN — fill in the two blanks to JOIN local + foreign data.
-- customers is LOCAL; remote.page_views is a FOREIGN table.

SELECT c.name, COUNT(*) AS views
FROM customers c
___ remote.page_views v               -- 👉 the keyword that combines two tables
    ON v.customer_id = c.___           -- 👉 the local column that matches v.customer_id
GROUP BY c.name
ORDER BY views DESC;

-- ✅ Expected result (3 rows):
--    Ava | 5 ,  Liam | 3 ,  Noor | 1
--    Blanks are:  JOIN  ,  id

3. dblink — Ad-Hoc Remote Queries

dblink runs a single query on a remote PostgreSQL server without defining a foreign table first. It's handy for one-off admin tasks. The catch: you must declare the result columns and their types every time, so it's verbose for anything you run often.

-- dblink runs a one-off query on a remote server without setting up
-- a foreign table first. Good for ad-hoc/admin work; verbose for daily use.

CREATE EXTENSION IF NOT EXISTS dblink;

SELECT *
FROM dblink(
    'host=shop-db dbname=shop user=reader password=secret',
    'SELECT order_id, total FROM orders WHERE total > 100'
) AS t(order_id INT, total NUMERIC);   -- you MUST declare the column types here

4. SQL Server — Linked Servers, 4-Part Names & OPENQUERY

SQL Server's equivalent is a linked server. After registering one with sp_addlinkedserver, you can address remote tables with a 4-part name [Server].[Database].[Schema].[Table].

But 4-part names can drag a whole remote table back to be filtered locally. OPENQUERY instead sends your query string to run on the remote server, so the filtering and aggregation happen there and only the result returns — much better pushdown.

-- SQL Server: a "linked server" registers another instance once...
EXEC sp_addlinkedserver
    @server = 'ANALYTICS', @srvproduct = '',
    @provider = 'SQLNCLI', @datasrc = 'analytics.example.com';

-- ...then you query it two ways:

-- (a) 4-part name  [Server].[Database].[Schema].[Table]
SELECT *
FROM ANALYTICS.marketing.dbo.campaigns;

-- (b) OPENQUERY — sends the inner query to run REMOTELY (better pushdown)
SELECT *
FROM OPENQUERY(ANALYTICS,
    'SELECT campaign_id, name FROM campaigns WHERE active = 1');

5. MySQL — The FEDERATED Engine

MySQL offers the FEDERATED storage engine: a local table that stores no data of its own and instead forwards every read and write to a table on another MySQL server, named by a connection URL. It must be enabled on the server (it's off by default), and it has real limits — no transactions across the link and limited index pushdown — so treat it as a convenience, not a foundation.

-- MySQL FEDERATED engine: a local table that is really a "shortcut"
-- to a table on another MySQL server. No data is stored locally.

CREATE TABLE remote_orders (
    order_id INT PRIMARY KEY,
    total    DECIMAL(10,2)
)
ENGINE = FEDERATED
CONNECTION = 'mysql://reader:secret@shop-host:3306/shop/orders';

-- Now query it like any table — MySQL forwards the request to shop-host:
SELECT order_id, total FROM remote_orders WHERE total > 100;

The Trade-Offs (read this before production)

Common Errors (and the fix)

Frequently Asked Questions

Q: Is a foreign table a copy of the remote data?

No. It stores no rows locally — it's a live pointer. Every query reaches across the network to the remote server, which is why latency and pushdown matter so much.

Q: postgres_fdw or dblink — which should I use?

Use postgres_fdw for anything recurring: you set it up once and then write normal SQL. Reach for dblink only for ad-hoc, one-off queries where defining a foreign table isn't worth it.

Q: Can I run one transaction across two databases?

Not safely with a plain BEGIN…COMMIT. A local commit can't undo a foreign write. True atomicity across systems needs two-phase commit, which most wrappers don't fully provide — so design writes to touch one system at a time.

Q: Why is my cross-database JOIN so slow?

Usually a filter didn't push down, so the whole remote table was fetched and filtered locally. Check the plan with EXPLAIN, keep WHERE conditions simple, and prefer OPENQUERY on SQL Server to force remote-side execution.

Mini-Challenge: Top Spenders Across Databases

Put it together — a brief, a blank canvas, and the expected result in the comments. Write it, then adapt it for a real FDW setup to confirm.

-- 🎯 MINI-CHALLENGE
-- A foreign table remote.orders(customer_id INT, total NUMERIC) already exists.
-- Using ONLY this lesson's ideas (foreign tables behave like local ones):
--   1. JOIN local customers (id, name) to remote.orders on customer_id = id
--   2. Sum total per customer  -> column called spend
--   3. Keep only customers whose spend is over 500 (filter the GROUP with HAVING)
--   4. Order highest spend first
--
-- ✅ Expected: name + spend, e.g.  Ava | 920 ,  Noor | 610

-- your query here

🎉 Lesson Complete

Practice quiz

What does a Foreign Data Wrapper (FDW) let PostgreSQL do?

  • Encrypt all network traffic automatically
  • Replicate data into the local database
  • Read another data source as if its tables were local tables
  • Compress remote tables

Answer: Read another data source as if its tables were local tables. An FDW like postgres_fdw teaches Postgres to query a remote source as local-looking tables.

What are the four setup steps for postgres_fdw, in order?

  • CREATE EXTENSION, CREATE SERVER, CREATE USER MAPPING, IMPORT FOREIGN SCHEMA
  • CREATE TABLE, INSERT, GRANT, COMMIT
  • CREATE INDEX, ANALYZE, VACUUM, REINDEX
  • CREATE ROLE, GRANT, REVOKE, DROP

Answer: CREATE EXTENSION, CREATE SERVER, CREATE USER MAPPING, IMPORT FOREIGN SCHEMA. Enable the wrapper, declare the server, map credentials, then import the remote schema.

Where are the credentials for connecting to a foreign server stored?

  • Inline in each SELECT statement
  • In the table's column definitions
  • In the client application only
  • In a USER MAPPING, not in every query

Answer: In a USER MAPPING, not in every query. CREATE USER MAPPING stores credentials once, so they are not repeated in queries.

What is 'pushdown' in a cross-database query?

  • Copying the whole remote table locally first
  • Sending the WHERE filter to the remote server so only matching rows travel back
  • Pushing the result into a local cache
  • Lowering the query's priority

Answer: Sending the WHERE filter to the remote server so only matching rows travel back. Pushdown evaluates filters on the remote side, the single biggest speed-up for FDW queries.

Does a foreign table store a local copy of the remote data?

  • No, it stores no rows locally; it is a live pointer queried over the network
  • Yes, it caches every row locally
  • Yes, it copies the data nightly
  • Only the first 1000 rows are copied

Answer: No, it stores no rows locally; it is a live pointer queried over the network. A foreign table holds no rows; each query reaches across the network to the remote server.

What does dblink do that an FDW foreign table does not require?

  • It encrypts the connection automatically
  • It caches results permanently
  • You must declare the result columns and their types every time
  • It works without any credentials

Answer: You must declare the result columns and their types every time. dblink runs an ad-hoc remote query but you must declare the column types each call.

On SQL Server, why does OPENQUERY often outperform a 4-part name?

  • It compresses the result set
  • It sends the inner query to run remotely, so filtering happens on the remote side
  • It caches the linked server connection
  • It bypasses authentication

Answer: It sends the inner query to run remotely, so filtering happens on the remote side. A 4-part name can drag the whole table back to filter locally; OPENQUERY pushes work remotely.

What is the MySQL FEDERATED engine?

  • A replication mode for MySQL clusters
  • A full-text search engine
  • A columnar storage format
  • A local table that stores no data and forwards reads/writes to a remote MySQL table

Answer: A local table that stores no data and forwards reads/writes to a remote MySQL table. FEDERATED is a local shortcut (a connection URL) to a table on another MySQL server.

Can a single BEGIN...COMMIT give atomicity across two databases via an FDW?

  • Yes, FDWs guarantee distributed transactions
  • No, a local commit cannot roll back a foreign write without two-phase commit
  • Yes, but only in MySQL
  • Only if both servers run the same version

Answer: No, a local commit cannot roll back a foreign write without two-phase commit. Transactions do not safely span systems; true atomicity needs two-phase commit, which most FDWs lack.

What is the main performance risk of every cross-database query?

  • It always uses a Seq Scan
  • It disables indexes locally
  • Network latency, since the query crosses a network each time
  • It doubles disk usage

Answer: Network latency, since the query crosses a network each time. Every foreign query crosses the network; filter early and return as few rows as possible.

Continue this course

Related lessons