PostgreSQL Foreign Table Performance Optimization in Practice

PostgreSQL’s Foreign Table is a powerful feature that allows us to query remote data sources as if they were local tables. However, in practice, especially when involving LEFT JOIN with Foreign Table primary keys, performance issues often become a headache. This article will deeply analyze the principles of Foreign Table and provide systematic performance optimization solutions.

How Foreign Table Works

To optimize Foreign Table, first we need to understand its working mechanism. Foreign Table is implemented based on Foreign Data Wrapper (FDW) architecture, and the entire query process can be divided into several stages:

Architecture Layers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SQL Query (SELECT * FROM foreign_tbl) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PostgreSQL Query Optimizer β”‚
β”‚ (Generate query plan, push down conditions)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Foreign Data Wrapper (FDW) β”‚
β”‚ (postgres_fdw, mysql_fdw, file_fdw)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ External Data Source (Remote PG/MySQL/File/API)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Core Components

A complete Foreign Table configuration contains three core components:

  1. Foreign Server β€” Define connection information to external data source
  2. User Mapping β€” Authentication mapping from local user to remote user
  3. Foreign Table β€” Local table structure definition, mapping to remote object
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 1. Create extension
CREATE EXTENSION postgres_fdw;

-- 2. Define foreign server
CREATE SERVER remote_db
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '192.168.1.100', dbname 'remote', port '5432');

-- 3. User mapping
CREATE USER MAPPING FOR current_user
SERVER remote_db
OPTIONS (user 'remote_user', password 'xxx');

-- 4. Define foreign table
CREATE FOREIGN TABLE foreign_users (
id int,
name text,
status text
) SERVER remote_db
OPTIONS (table_name 'users');

Query Execution Flow

When executing a query, PostgreSQL goes through the following steps:

1. Query Planning Phase

The optimizer identifies that the query involves Foreign Table, and calls the FDW’s PlanForeignScan callback function. This phase determines which query conditions can be β€œpushed down” to execute remotely.

2. Condition Pushdown

This is the key point for performance optimization. If WHERE conditions can be executed remotely, it can greatly reduce the amount of data transmitted over the network:

1
2
3
4
5
-- This query will push WHERE condition to remote execution
SELECT * FROM foreign_users WHERE id > 100;

-- FDW will generate remote query like this:
-- SELECT id, name, status FROM users WHERE id > 100

Currently, pushdownable content includes:

  • WHERE conditions
  • JOIN operations (some FDW support, postgres_fdw 15+ support)
  • Aggregation functions (SUM, COUNT, AVG, etc.)
  • Sorting and LIMIT

3. Execution Phase

  • BeginForeignScan: Establish connection to remote database
  • IterateForeignScan: Iterate to get data rows
  • EndForeignScan: Release connection resources

LEFT JOIN Foreign Table Performance Issues

When local table LEFT JOIN Foreign Table, default behavior often performs poorly:

1
2
3
SELECT l.*, r.name 
FROM local_table l
LEFT JOIN foreign_table r ON l.foreign_id = r.id;

The problems are:

  1. Full table fetch: All data from Foreign Table is fetched locally then JOIN
  2. Missing statistics: Optimizer doesn’t know how many rows Foreign Table has, cannot make optimal plan
  3. Network round-trip: Each query establishes connection, high overhead
  4. Missing index information: Optimizer doesn’t know if remote table has indexes

Performance Optimization Strategies

1. Import Statistics

The most effective optimization is to let the optimizer understand Foreign Table statistics:

1
2
3
-- Let FDW query remote table statistics
ALTER FOREIGN TABLE foreign_users
OPTIONS (use_remote_estimate 'true');

use_remote_estimate will make postgres_fdw query remote table statistics (row count, unique value count, etc.) during planning phase, thus generating better execution plans.

Trade-off: This will increase planning phase time (requires extra remote query), but usually can get better execution plan. Recommend enabling when Foreign Table data volume is large or query is complex.

2. Optimize Batch Fetch

postgres_fdw by default fetches 100 rows from remote each time. Increasing this value can reduce network round-trip count:

1
2
3
4
5
6
7
-- Server level setting
ALTER SERVER remote_db
OPTIONS (SET fetch_size '10000');

-- Single table level setting (higher priority)
ALTER FOREIGN TABLE foreign_users
OPTIONS (SET fetch_size '5000');

Recommended values:

  • Small table or simple query: 100-1000
  • Large table or complex query: 5000-10000
  • Very large table: can try 20000

3. Condition Pushdown Optimization

Ensure query conditions can be pushed to remote execution:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Bad: First fetch all then JOIN
SELECT l.*, r.name
FROM local_table l
LEFT JOIN foreign_table r ON l.foreign_id = r.id;

-- Good: Reduce local table row count
SELECT l.*, r.name
FROM local_table l
LEFT JOIN foreign_table r ON l.foreign_id = r.id
WHERE l.some_column = 'value';

-- Better: Directly filter Foreign Table
SELECT l.*, r.name
FROM local_table l
LEFT JOIN foreign_table r
ON l.foreign_id = r.id AND r.status = 'active';

In the second query, r.status = 'active' will be executed remotely, reducing returned data volume.

Verify Condition Pushdown:

Use EXPLAIN (VERBOSE) to see if pushdown succeeded:

1
2
3
4
5
EXPLAIN (VERBOSE, COSTS OFF) 
SELECT * FROM foreign_users WHERE id > 100;

-- If you see "Remote SQL: SELECT ... FROM users WHERE (id > 100)"
-- It means condition pushdown succeeded

4. JOIN Pushdown (postgres_fdw 15+)

Starting from PostgreSQL 15, postgres_fdw supports JOIN pushdown. This means JOIN between two Foreign Tables can be completely executed remotely:

1
2
3
4
5
-- JOIN between two Foreign Tables can be pushed down
SELECT a.*, b.name
FROM foreign_table_a a
JOIN foreign_table_b b ON a.b_id = b.id
WHERE a.status = 'active';

If JOIN cannot be pushed down, can try:

1
2
3
4
-- Temporarily disable hash join and merge join
SET enable_hashjoin = off;
SET enable_mergejoin = off;
-- Force optimizer to use nested loop, may trigger pushdown

5. Index Strategy

Remote table must have indexes:

1
2
3
4
-- Create on remote database
CREATE INDEX idx_users_id ON users(id);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_id_status ON users(id, status);

Local table join key also needs index:

1
2
-- Create on local database
CREATE INDEX idx_local_foreign_id ON local_table(foreign_id);

6. Use CTE Pre-filter

For complex queries, using MATERIALIZED CTE can first filter Foreign Table data:

1
2
3
4
5
6
WITH filtered_foreign AS MATERIALIZED (
SELECT id, name FROM foreign_table WHERE status = 'active'
)
SELECT l.*, f.name
FROM local_table l
LEFT JOIN filtered_foreign f ON l.foreign_id = f.id;

MATERIALIZED keyword ensures CTE executes only once, result is materialized and reused.

7. Materialized View Alternative

If Foreign Table data changes infrequently, can create materialized view:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Create materialized view
CREATE MATERIALIZED VIEW mv_foreign_users AS
SELECT id, name, status FROM foreign_table;

-- Create index to speed up query
CREATE UNIQUE INDEX idx_mv_foreign_users_id ON mv_foreign_users(id);
CREATE INDEX idx_mv_foreign_users_status ON mv_foreign_users(status);

-- Periodic refresh
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_foreign_users;

-- Query uses materialized view
SELECT l.*, m.name
FROM local_table l
LEFT JOIN mv_foreign_users m ON l.foreign_id = m.id;

Materialized view advantages:

  • Query speed as fast as local table
  • Can create local indexes
  • No need to connect remote every time

Disadvantages:

  • Data has delay
  • Needs periodic refresh
  • Occupies local storage space

8. Partitioned Table + Foreign Table

For time series data, can put hot data locally, cold data remotely:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Local partitioned table definition
CREATE TABLE logs (
id bigint,
created_at date,
data text
) PARTITION BY RANGE (created_at);

-- Hot data locally
CREATE TABLE logs_hot PARTITION OF logs
FOR VALUES FROM ('2026-01-01') TO ('2026-07-01');

-- Cold data remotely
CREATE FOREIGN TABLE logs_cold PARTITION OF logs
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01')
SERVER remote_archive
OPTIONS (table_name 'logs_archive');

When querying, PostgreSQL automatically routes to correct partition:

1
2
3
4
5
6
-- Only query local hot data
SELECT * FROM logs WHERE created_at >= '2026-03-01';

-- Query spans cold and hot data
SELECT * FROM logs WHERE created_at >= '2025-06-01';
-- PostgreSQL will query both local and remote in parallel

Performance Comparison Example

Take a real scenario: 100,000 rows local table LEFT JOIN 1,000,000 rows remote table.

Before Optimization

1
2
3
4
5
6
7
8
-- Default configuration, no statistics
EXPLAIN ANALYZE
SELECT l.*, r.name
FROM local_table l
LEFT JOIN foreign_table r ON l.foreign_id = r.id;

-- Execution time: 45 seconds
-- Reason: Full table fetch, nested loop JOIN

After Optimization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- Optimization configuration
ALTER SERVER remote_db OPTIONS (SET fetch_size '10000');
ALTER FOREIGN TABLE foreign_table OPTIONS (use_remote_estimate 'true');

-- Create index on remote
CREATE INDEX idx_ft_id ON remote_table(id);

EXPLAIN ANALYZE
SELECT l.*, r.name
FROM local_table l
LEFT JOIN foreign_table r ON l.foreign_id = r.id;

-- Execution time: 3 seconds
-- 15x improvement

Optimization Checklist

When encountering Foreign Table performance issues, check in this order:

  1. Remote table index: Does Foreign Table joined column have index?
  2. Local table index: Does local table join key have index?
  3. Statistics: Is use_remote_estimate enabled?
  4. Batch fetch: Is fetch_size large enough?
  5. Condition pushdown: Can WHERE conditions be pushed to remote? Verify with EXPLAIN (VERBOSE)
  6. JOIN pushdown: Can multiple Foreign Table JOIN be pushed down?
  7. Data volume: Is it suitable to use materialized view?
  8. Data temperature: Can cold/hot data be partitioned?

Spring Data JPA Scenario Optimization

When using Spring Data JPA to access joined tables, besides the above database-level optimizations, need to solve ORM layer specific performance issues, mainly N+1 problem.

Problem Root

1
2
3
4
5
6
7
8
9
10
11
12
@Entity
public class Order {
@Id private Long id;
@ManyToOne(fetch = FetchType.LAZY) // Or EAGER
@JoinColumn(name = "user_id")
private User user; // Join foreign table

// Query 100 orders
List<Order> orders = orderRepository.findAll();
// EAGER: 1 + 100 SQL (N+1 problem)
// LAZY: Accessing user property triggers 100 extra queries
}

Optimization Strategies

1. JOIN FETCH (Most Direct)

1
2
3
// Repository method
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findWithUserByStatus(@Param("status") String status);

Generated SQL:

1
2
3
4
SELECT o.*, u.* 
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = ?

Suitable scenarios: Clearly know need joined data, data volume controllable

2. EntityGraph (Declarative)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
@NamedEntityGraph(
name = "Order.withUser",
attributeNodes = @NamedAttributeNode("user")
)
public class Order { ... }

// Repository
@EntityGraph("Order.withUser")
List<Order> findByStatus(String status);

// Or dynamically specify
@EntityGraph(attributePaths = {"user"})
List<Order> findByStatus(String status);

Advantages:

  • Doesn’t pollute JPQL
  • Reusable
  • Supports multi-layer nesting: attributePaths = {"user", "user.department"}

3. Batch Fetching

1
2
3
4
5
6
7
8
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
# Or configure per entity
batch_fetch_style: PADDED
1
2
3
4
5
6
@Entity
@BatchSize(size = 50) // Batch load 50 associations each time
public class Order {
@ManyToOne
private User user;
}

Principle: Originally 100 times SELECT * FROM users WHERE id = ? becomes:

1
SELECT * FROM users WHERE id IN (?, ?, ?, ..., ?)  -- 2 times, 50 each

Suitable scenarios: Cannot predict whether need joined data, but hope to batch load when accessing

4. DTO Projection (Only Take What’s Needed)

1
2
3
4
5
6
7
8
9
10
11
// Interface projection
public interface OrderSummary {
Long getId();
String getProductName();
String getUserName(); // From joined table
}

// Repository
@Query("SELECT o.id as id, o.productName as productName, u.name as userName " +
"FROM Order o JOIN o.user u WHERE o.status = :status")
List<OrderSummary> findSummariesByStatus(@Param("status") String status);

Advantages:

  • Single SQL, no extra query
  • Don’t load complete entity, small memory footprint
  • Suitable for list display, report scenarios

5. Subquery + IN Condition (Stepwise Query)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Step 1: Query main table
List<Order> orders = orderRepository.findByStatus("PAID");
List<Long> userIds = orders.stream()
.map(Order::getUserId)
.distinct()
.toList();

// Step 2: Batch query joined table
Map<Long, User> userMap = userRepository.findByIdIn(userIds)
.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));

// Step 3: Memory assembly
orders.forEach(o -> o.setUser(userMap.get(o.getUserId())));

Suitable scenarios:

  • Joined table is Foreign Table, need separately optimize query
  • Join condition complex, JPQL difficult to express
  • Need to cache joined data

6. Second-level Cache + Cache Penetration Protection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class User {
@Id private Long id;
private String name;
}

// Configure cache
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(30)));
return manager;
}
}

Suitable scenarios: Joined table data changes little, query frequently

7. Native Query + ResultSetMapping

For Foreign Table, directly writing native SQL may be more controllable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@SqlResultSetMapping(
name = "OrderWithUserMapping",
entities = {
@EntityResult(entityClass = Order.class),
@EntityResult(entityClass = User.class)
}
)
@Entity
public class Order { ... }

// Repository
@Query(value = """
SELECT o.*, u.*
FROM orders o
LEFT JOIN foreign_users u ON o.user_id = u.id
WHERE o.status = :status
""", nativeQuery = true)
List<Object[]> findOrdersWithUsersNative(@Param("status") String status);

// Or use projection
@Query(value = """
SELECT o.id, o.product_name, u.name as user_name
FROM orders o
LEFT JOIN foreign_users u ON o.user_id = u.id
""", nativeQuery = true)
List<OrderUserDto> findOrderUserProjection();

8. Pagination Optimization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Problem: JOIN FETCH + pagination = memory full table query
@Query("SELECT o FROM Order o JOIN FETCH o.user")
Page<Order> findAll(Pageable pageable); // Warning: Full table load to memory

// Solution 1: Two-step query
@Query(value = "SELECT o FROM Order o WHERE o.status = :status",
countQuery = "SELECT COUNT(o) FROM Order o WHERE o.status = :status")
Page<Order> findByStatus(@Param("status") String status, Pageable pageable);

// Then use @BatchSize or EntityGraph to load association

// Solution 2: Subquery + IN
@Query("SELECT o FROM Order o WHERE o.id IN " +
"(SELECT o2.id FROM Order o2 WHERE o2.status = :status)")
List<Order> findIdsByStatus(@Param("status") String status, Pageable pageable);

JPA Strategy Performance Comparison

Strategy SQL Count Suitable Scenarios Memory Usage
LAZY (default) 1 + N Occasionally access joined data Low
EAGER 1 + N Not recommended High
JOIN FETCH 1 Clearly need joined data Medium
EntityGraph 1 Reusable configuration Medium
@BatchSize 1 + N/M Unsure whether need join Medium
DTO Projection 1 Only need partial fields Low
Subquery Stepwise 2 Need separately optimize join query Medium
Second-level Cache 0 (when hit) Joined data changes little Medium

Foreign Table + JPA Special Recommendations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 1. Use DTO projection, avoid entity management overhead
public interface OrderUserView {
Long getId();
String getOrderNo();
@Value("#{target.user_name}") // Foreign table field mapping
String getUserName();
}

// 2. Native Query directly control SQL
@Query(value = """
SELECT o.id, o.order_no, u.name as user_name, u.status as user_status
FROM orders o
LEFT JOIN foreign_users u ON o.user_id = u.id
WHERE o.created_at > :since
ORDER BY o.id
LIMIT :limit OFFSET :offset
""", nativeQuery = true)
List<OrderUserView> findRecentOrders(
@Param("since") LocalDateTime since,
@Param("limit") int limit,
@Param("offset") int offset
);

// 3. Stepwise query + local cache
@Service
public class OrderService {

private final LoadingCache<Long, User> userCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(10))
.build(this::loadUser);

private User loadUser(Long userId) {
return userRepository.findById(userId)
.orElse(User.EMPTY);
}

public List<OrderVO> getOrdersWithUsers(List<Long> orderIds) {
List<Order> orders = orderRepository.findByIdIn(orderIds);
return orders.stream()
.map(o -> new OrderVO(o, userCache.get(o.getUserId())))
.toList();
}
}

Quick Decision Flow

1
2
3
4
5
6
7
8
9
Need joined data?
β”œβ”€β”€ No β†’ Use DTO projection, don't load association
└── Yes
β”œβ”€β”€ Data volume controllable?
β”‚ β”œβ”€β”€ Yes β†’ JOIN FETCH / EntityGraph
β”‚ └── No β†’ @BatchSize + pagination
└── Joined table is Foreign Table?
β”œβ”€β”€ Yes β†’ DTO projection + Native Query
└── No β†’ Choose from above strategies based on scenario

Summary

The core idea of Foreign Table performance optimization is let computation push down to data source, reducing network transmission and local computation. The most effective optimizations are usually:

  1. Increase fetch_size β€” Reduce network round-trip
  2. Enable use_remote_estimate β€” Let optimizer make better decisions
  3. Ensure remote index β€” Speed up remote query
  4. Condition pushdown β€” Let filtering execute remotely

In Spring Data JPA scenarios, also need to solve ORM layer N+1 problem, recommended strategies:

  • JOIN FETCH / EntityGraph: Clearly need joined data
  • @BatchSize: Unsure whether need joined data
  • DTO Projection: Only need partial fields, especially Foreign Table scenarios
  • Native Query: Directly control SQL, maximize condition pushdown

For scenarios where data changes infrequently, materialized view is the simplest and most efficient solution. For time series data, partitioned table + Foreign Table can achieve cold/hot separation, balancing query performance and storage cost.

Master these techniques, Foreign Table can become a sharp tool in data federation architecture, not a performance bottleneck.

Menu