AWS RDS Proxy: Connection Pooling for Serverless and Microservices (2026)
Lambda scales to thousands of concurrent executions in seconds. Each one tries to open a fresh database connection. Your PostgreSQL instance supports 500 max connections. The math is brutal. AWS RDS Proxy exists precisely to solve this problem — and it does much more than connection pooling.
Table of Contents
- 1. The Connection Exhaustion Problem
- 2. How RDS Proxy Works — Multiplexing and Pinning
- 3. Setting Up RDS Proxy (Console, CLI, Terraform)
- 4. Lambda + RDS Proxy — Before vs After
- 5. ECS and EKS Microservices with RDS Proxy
- 6. IAM Authentication — Python and Java Examples
- 7. Monitoring with CloudWatch Metrics
- 8. Cost Analysis — When It Pays for Itself
- 9. Common Pitfalls and Pinning Causes
- FAQ
1. The Connection Exhaustion Problem
Every relational database engine has a hard ceiling on the number of simultaneous connections it can hold open. These limits exist because each connection consumes memory (PostgreSQL allocates roughly 5–10 MB per backend process, MySQL keeps a thread stack per connection). For a db.t3.medium RDS PostgreSQL instance, AWS sets max_connections to approximately 170. For db.r6g.xlarge it is around 1,200.
This ceiling was designed for the connection patterns of traditional monolithic applications — a handful of application servers each maintaining a fixed-size connection pool. A Node.js app running on three EC2 instances with a pool of 20 connections each uses 60 connections total. That is fine.
Serverless and container workloads break this model completely:
- AWS Lambda can scale from zero to 3,000 concurrent executions within seconds (default burst limit). Every cold-started function opens a new connection. Even warm functions may open connections if they were not pooling correctly. A burst of 500 Lambda invocations means 500 simultaneous connection attempts.
- ECS Fargate tasks scale out per request. Each task has its own in-process connection pool — 50 tasks × 10-connection pool = 500 connections before you even hit load.
- Kubernetes pods with HPA autoscaling have identical problems. A deployment that scales from 5 to 80 replicas during a traffic spike multiplies connection usage 16-fold instantly.
psycopg2.connect() at the top of the handler. The RDS instance (max 500 connections) immediately starts rejecting new connections. Customers see "FATAL: remaining connection slots are reserved for non-replication superuser connections" errors. Orders are lost.
Beyond connection limits, frequent connect/disconnect cycles are expensive. PostgreSQL's TLS handshake + authentication + session setup can take 20–50 ms per connection. For a Lambda that runs a 5 ms query, connection overhead dominates total execution time. RDS Proxy eliminates this by reusing long-lived backend connections across thousands of short-lived client connections.
2. How RDS Proxy Works — Multiplexing and Pinning
RDS Proxy sits between your application and your RDS or Aurora database. It exposes its own DNS endpoint. Your application connects to the proxy endpoint instead of the database endpoint directly. The proxy maintains a warm pool of long-lived connections to the actual database and multiplexes many client connections onto that smaller pool.
Connection Multiplexing
The core mechanism is connection multiplexing (also called connection reuse). When a client connection executes a query and returns to an idle state, RDS Proxy can reassign that backend database connection to serve a different client. The proxy tracks session state per client (prepared statements, temporary tables, transaction isolation level) and ensures correctness when sharing backend connections.
The result: 1,000 Lambda functions can connect to the proxy simultaneously while the proxy uses only 50 backend connections to the database. The multiplexing ratio (clients : backend connections) can exceed 20:1 in practice for short-running query workloads.
Connection Pinning
Multiplexing has one important caveat: pinning. If a client connection enters a state that RDS Proxy cannot safely share — an open transaction, a SET statement, a prepared statement, certain session-level configuration changes — the proxy pins that client to a single backend connection for the duration. A pinned connection cannot be reused by other clients until the client releases it (commits/rolls back, closes).
Pinning is safe and correct — it never causes data corruption. But excessive pinning eliminates the multiplexing benefit. Monitoring the DatabaseConnectionsCurrentlySessionPinned CloudWatch metric tells you how often your workload triggers pinning.
- Using
SETstatements to modify session variables (e.g.,SET search_pathin PostgreSQL) - Using temporary tables or temporary sequences
- Calling stored procedures that use cursors
- Using the
ROLLBACK TO SAVEPOINTwith nested transactions - Using MySQL
LOCK TABLESorGET_LOCK()
IAM Authentication and Secrets Manager Integration
RDS Proxy integrates natively with AWS Secrets Manager. You store your database credentials in Secrets Manager, and the proxy authenticates to the database on your behalf. Client applications authenticate to the proxy using either IAM tokens (recommended for Lambda/containers) or standard username/password credentials. This centralises secret rotation — when Secrets Manager rotates the database password, the proxy picks up the new credential without any application restart or downtime.
Failover Handling
During a Multi-AZ failover (when RDS promotes the standby to primary), standard applications must wait for DNS propagation and re-establish connections — typically 30–120 seconds of errors. With RDS Proxy, the proxy maintains its own endpoint (which never changes DNS) and reconnects to the new primary internally. Client connection disruption is reduced to seconds rather than minutes.
3. Setting Up RDS Proxy (Console, CLI, Terraform)
Prerequisites
Before creating a proxy you need:
- An RDS or Aurora database in a VPC
- A Secrets Manager secret containing the database username and password
- An IAM role that grants RDS Proxy permission to read that secret
- A security group for the proxy (must allow inbound on the DB port from your application SGs, and outbound to the DB SG)
Step 1 — Create the Secrets Manager Secret
aws secretsmanager create-secret \
--name myapp/rds/proxy-credentials \
--description "RDS credentials for RDS Proxy" \
--secret-string '{
"username": "appuser",
"password": "Sup3rS3cur3P@ssword",
"engine": "postgres",
"host": "myapp-prod.cluster-xyz.us-east-1.rds.amazonaws.com",
"port": 5432,
"dbname": "myappdb"
}'
Step 2 — Create the IAM Role
# Create trust policy for RDS Proxy service
cat > rds-proxy-trust.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "rds.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name rds-proxy-secrets-role \
--assume-role-policy-document file://rds-proxy-trust.json
# Create permission policy to read secrets
cat > rds-proxy-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/rds/*"
},{
"Effect": "Allow",
"Action": ["kms:Decrypt"],
"Resource": "arn:aws:kms:us-east-1:123456789012:key/YOUR-KMS-KEY-ID",
"Condition": {
"StringEquals": {"kms:ViaService": "secretsmanager.us-east-1.amazonaws.com"}
}
}]
}
EOF
aws iam put-role-policy \
--role-name rds-proxy-secrets-role \
--policy-name rds-proxy-secrets-access \
--policy-document file://rds-proxy-policy.json
Step 3 — Create the Proxy (AWS CLI)
aws rds create-db-proxy \
--db-proxy-name myapp-proxy \
--engine-family POSTGRESQL \
--auth '[{
"AuthScheme": "SECRETS",
"SecretArn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/rds/proxy-credentials",
"IAMAuth": "REQUIRED"
}]' \
--role-arn arn:aws:iam::123456789012:role/rds-proxy-secrets-role \
--vpc-subnet-ids subnet-0abc1234 subnet-0def5678 subnet-0ghi9012 \
--vpc-security-group-ids sg-proxy123456 \
--require-tls \
--idle-client-timeout 1800 \
--debug-logging false \
--tags Key=Environment,Value=production Key=App,Value=myapp
# Register the target (the RDS/Aurora instance or cluster)
aws rds register-db-proxy-targets \
--db-proxy-name myapp-proxy \
--db-cluster-identifiers myapp-aurora-cluster
# OR for standard RDS: --db-instance-identifiers myapp-prod
Terraform Configuration
For infrastructure-as-code, here is a complete Terraform module for RDS Proxy with Aurora PostgreSQL:
# variables.tf
variable "vpc_id" {}
variable "subnet_ids" { type = list(string) }
variable "app_security_group_id" {}
variable "db_cluster_identifier" {}
variable "secret_arn" {}
variable "kms_key_arn" {}
# security_group.tf — proxy SG allows app SG inbound on 5432
resource "aws_security_group" "rds_proxy" {
name = "rds-proxy-sg"
description = "RDS Proxy security group"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.app_security_group_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# iam.tf
data "aws_iam_policy_document" "rds_proxy_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["rds.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "rds_proxy_permissions" {
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = [var.secret_arn]
}
statement {
actions = ["kms:Decrypt"]
resources = [var.kms_key_arn]
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = ["secretsmanager.${data.aws_region.current.name}.amazonaws.com"]
}
}
}
resource "aws_iam_role" "rds_proxy" {
name = "rds-proxy-role"
assume_role_policy = data.aws_iam_policy_document.rds_proxy_assume.json
}
resource "aws_iam_role_policy" "rds_proxy" {
name = "rds-proxy-secrets-policy"
role = aws_iam_role.rds_proxy.id
policy = data.aws_iam_policy_document.rds_proxy_permissions.json
}
# proxy.tf
resource "aws_db_proxy" "main" {
name = "myapp-proxy"
debug_logging = false
engine_family = "POSTGRESQL"
idle_client_timeout = 1800
require_tls = true
role_arn = aws_iam_role.rds_proxy.arn
vpc_security_group_ids = [aws_security_group.rds_proxy.id]
vpc_subnet_ids = var.subnet_ids
auth {
auth_scheme = "SECRETS"
description = "Aurora credentials"
iam_auth = "REQUIRED"
secret_arn = var.secret_arn
}
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
resource "aws_db_proxy_default_target_group" "main" {
db_proxy_name = aws_db_proxy.main.name
connection_pool_config {
connection_borrow_timeout = 120 # seconds to wait for a connection
max_connections_percent = 90 # % of DB max_connections to use
max_idle_connections_percent = 50 # % to keep warm even when idle
session_pinning_filters = ["EXCLUDE_VARIABLE_SETS"] # reduces pinning
}
}
resource "aws_db_proxy_target" "main" {
db_proxy_name = aws_db_proxy.main.name
target_group_name = aws_db_proxy_default_target_group.main.name
db_cluster_identifier = var.db_cluster_identifier
}
output "proxy_endpoint" {
value = aws_db_proxy.main.endpoint
}
max_connections_percent = 90— RDS Proxy will use up to 90% of the database'smax_connections. Leave 10% for direct admin access.connection_borrow_timeout = 120— How long a client waits for a backend connection before getting an error. Tune based on your SLA.session_pinning_filters = ["EXCLUDE_VARIABLE_SETS"]— Tells the proxy to NOT pin connections when clients sendSETvariable statements. This is safe for most apps and dramatically reduces pinning.
4. Lambda + RDS Proxy — Before vs After
The Problem: Lambda Without a Proxy
The classic anti-pattern is initialising a new database connection inside the Lambda handler function body — meaning a new connection is created for every invocation:
# BAD: Connection inside handler — new connection every invocation
import psycopg2
import os
def lambda_handler(event, context):
# This runs on EVERY invocation including warm starts
conn = psycopg2.connect(
host=os.environ['DB_HOST'],
database=os.environ['DB_NAME'],
user=os.environ['DB_USER'],
password=os.environ['DB_PASSWORD']
)
cur = conn.cursor()
cur.execute("SELECT * FROM orders WHERE id = %s", (event['order_id'],))
result = cur.fetchone()
cur.close()
conn.close()
return {"order": result}
The Better Pattern: Connection Reuse in Lambda
Even without RDS Proxy, you should initialise connections outside the handler so they survive across warm invocations. But warm connections still die during cold starts, and each new execution environment creates a new connection:
# BETTER: Connection outside handler — reused across warm invocations
import psycopg2
import os
# Runs once per execution environment (container), reused on warm invocations
conn = None
def get_connection():
global conn
if conn is None or conn.closed:
conn = psycopg2.connect(
host=os.environ['DB_PROXY_HOST'], # Proxy endpoint!
database=os.environ['DB_NAME'],
user=os.environ['DB_USER'],
password=os.environ['DB_PASSWORD'],
connect_timeout=5,
sslmode='require'
)
return conn
def lambda_handler(event, context):
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM orders WHERE id = %s", (event['order_id'],))
result = cur.fetchone()
cur.close()
return {"order": result}
The Best Pattern: Lambda + RDS Proxy + IAM Auth
With RDS Proxy in front, each Lambda execution environment holds a persistent connection to the proxy. The proxy multiplexes hundreds of these onto a much smaller pool of backend database connections. Here is the production-ready pattern using IAM token authentication:
import psycopg2
import boto3
import os
import json
# Module-level: survives warm invocations
rds_client = boto3.client('rds', region_name=os.environ['AWS_REGION'])
_conn = None
def generate_iam_token():
"""Generate a short-lived IAM auth token for RDS Proxy."""
return rds_client.generate_db_auth_token(
DBHostname=os.environ['PROXY_ENDPOINT'],
Port=5432,
DBUsername=os.environ['DB_USER'],
Region=os.environ['AWS_REGION']
)
def get_connection():
global _conn
if _conn is None or _conn.closed:
token = generate_iam_token()
_conn = psycopg2.connect(
host=os.environ['PROXY_ENDPOINT'],
port=5432,
dbname=os.environ['DB_NAME'],
user=os.environ['DB_USER'],
password=token, # IAM token as password
sslmode='verify-full',
sslrootcert='/var/task/rds-combined-ca-bundle.pem'
)
_conn.autocommit = False
return _conn
def lambda_handler(event, context):
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id, status, total FROM orders WHERE id = %s",
(event['order_id'],)
)
row = cur.fetchone()
if row is None:
return {'statusCode': 404, 'body': 'Order not found'}
return {
'statusCode': 200,
'body': json.dumps({
'id': row[0],
'status': row[1],
'total': float(row[2])
})
}
except psycopg2.OperationalError:
# Force reconnect on next invocation if connection broke
global _conn
_conn = None
raise
The Lambda execution role needs the following IAM permission to authenticate via IAM tokens:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "rds-db:connect",
"Resource": "arn:aws:rds-db:us-east-1:123456789012:dbuser:prx-0abc123def456789/appuser"
}]
}
Connection Count Comparison
| Scenario | Lambda Concurrency | DB Connections Used | Risk |
|---|---|---|---|
| No proxy, connection in handler | 500 | 500 | Exhaustion at max_connections |
| No proxy, connection outside handler | 500 (warm) | 500 (one per env) | Same — each env has one |
| With RDS Proxy (90% pool limit) | 500 | ~45 (on db.t3.medium) | Proxy queues excess clients |
| With RDS Proxy | 3,000 | ~45 | None — proxy handles queuing |
5. ECS and EKS Microservices with RDS Proxy
Container workloads share the same connection problem as Lambda but with slight differences. A microservice running in ECS Fargate typically uses an in-process connection pool (PgBouncer, HikariCP, SQLAlchemy pool). When HPA or ECS autoscaling spins up 40 new task replicas, each starts its own pool — multiplying database connections dramatically.
ECS Task Definition with Proxy Environment Variables
{
"family": "order-service",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789012:role/order-service-task-role",
"containerDefinitions": [{
"name": "order-service",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/order-service:latest",
"environment": [
{"name": "DB_HOST", "value": "myapp-proxy.proxy-xyz.us-east-1.rds.amazonaws.com"},
{"name": "DB_PORT", "value": "5432"},
{"name": "DB_NAME", "value": "orders"},
{"name": "USE_IAM_AUTH", "value": "true"}
],
"secrets": [
{"name": "DB_USER", "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/rds/proxy-credentials:username::"}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/order-service",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}]
}
Kubernetes Deployment with RDS Proxy
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: production
spec:
replicas: 5
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
annotations:
# IRSA: EKS pod identity for RDS IAM auth
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/order-service-pod-role
spec:
serviceAccountName: order-service-sa
containers:
- name: order-service
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/order-service:latest
env:
- name: DB_HOST
value: "myapp-proxy.proxy-xyz.us-east-1.rds.amazonaws.com"
- name: DB_PORT
value: "5432"
- name: DB_POOL_SIZE
value: "5" # Small pool per pod — proxy handles the rest
- name: DB_POOL_MAX_OVERFLOW
value: "2"
- name: USE_IAM_AUTH
value: "true"
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
6. IAM Authentication — Python and Java Examples
IAM authentication is the recommended approach for Lambda, ECS, and EKS workloads. Instead of a static password, your application generates a short-lived authentication token (valid for 15 minutes) signed using the AWS credentials of the IAM role. No passwords stored in application code or environment variables.
Python (boto3 + psycopg2)
import boto3
import psycopg2
import ssl
import os
def create_rds_connection_iam():
"""
Connect to RDS Proxy using IAM token authentication.
Call this once per Lambda execution environment (module level).
"""
region = os.environ.get('AWS_REGION', 'us-east-1')
host = os.environ['PROXY_ENDPOINT']
port = int(os.environ.get('DB_PORT', 5432))
user = os.environ['DB_USER']
dbname = os.environ['DB_NAME']
# Generate 15-minute auth token
client = boto3.client('rds', region_name=region)
token = client.generate_db_auth_token(
DBHostname=host,
Port=port,
DBUsername=user,
Region=region
)
# SSL context — RDS Proxy requires TLS
ssl_context = ssl.create_default_context()
ssl_context.load_verify_locations('/etc/ssl/certs/ca-bundle.crt')
conn = psycopg2.connect(
host=host,
port=port,
dbname=dbname,
user=user,
password=token,
sslmode='verify-full',
sslrootcert='/etc/ssl/certs/ca-bundle.crt'
)
return conn
# Usage with context manager for automatic transaction handling
def get_orders_for_customer(customer_id: int):
conn = create_rds_connection_iam()
try:
with conn.cursor() as cur:
cur.execute(
"""SELECT o.id, o.created_at, o.total, o.status
FROM orders o
WHERE o.customer_id = %s
ORDER BY o.created_at DESC
LIMIT 10""",
(customer_id,)
)
return cur.fetchall()
finally:
conn.close()
Java (JDBC + AWS SDK v2)
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.rds.RdsUtilities;
import software.amazon.awssdk.services.rds.model.GenerateAuthenticationTokenRequest;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.Properties;
public class RdsProxyIamConnection {
private static final String PROXY_HOST = System.getenv("PROXY_ENDPOINT");
private static final int PROXY_PORT = 5432;
private static final String DB_NAME = System.getenv("DB_NAME");
private static final String DB_USER = System.getenv("DB_USER");
private static final Region REGION = Region.US_EAST_1;
/**
* Creates a JDBC connection to RDS Proxy using IAM authentication.
* The auth token is valid for 15 minutes — regenerate for long-lived pools.
*/
public static Connection createConnection() throws Exception {
// Generate IAM auth token
RdsUtilities utilities = RdsUtilities.builder()
.credentialsProvider(DefaultCredentialsProvider.create())
.region(REGION)
.build();
String authToken = utilities.generateAuthenticationToken(
GenerateAuthenticationTokenRequest.builder()
.hostname(PROXY_HOST)
.port(PROXY_PORT)
.username(DB_USER)
.build()
);
// JDBC connection properties
Properties props = new Properties();
props.setProperty("user", DB_USER);
props.setProperty("password", authToken);
props.setProperty("ssl", "true");
props.setProperty("sslmode", "verify-full");
props.setProperty("sslfactory", "org.postgresql.ssl.DefaultJavaSSLFactory");
String jdbcUrl = String.format(
"jdbc:postgresql://%s:%d/%s",
PROXY_HOST, PROXY_PORT, DB_NAME
);
return DriverManager.getConnection(jdbcUrl, props);
}
}
// HikariCP pool with IAM token refresh
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
public class RdsProxyDataSource {
public static HikariDataSource buildDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(String.format("jdbc:postgresql://%s:5432/%s",
System.getenv("PROXY_ENDPOINT"),
System.getenv("DB_NAME")));
config.setUsername(System.getenv("DB_USER"));
// For IAM auth, password is set via a custom DataSource or refreshed periodically
config.setPassword(generateToken());
config.setMaximumPoolSize(10); // Small pool — proxy handles scale
config.setMinimumIdle(2);
config.setConnectionTimeout(5_000); // 5 seconds
config.setIdleTimeout(600_000); // 10 minutes
config.setMaxLifetime(840_000); // 14 min — less than 15 min token TTL
config.addDataSourceProperty("ssl", "true");
config.addDataSourceProperty("sslmode", "verify-full");
return new HikariDataSource(config);
}
private static String generateToken() {
RdsUtilities utils = RdsUtilities.builder()
.region(Region.US_EAST_1).build();
return utils.generateAuthenticationToken(
GenerateAuthenticationTokenRequest.builder()
.hostname(System.getenv("PROXY_ENDPOINT"))
.port(5432)
.username(System.getenv("DB_USER"))
.build());
}
}
maxLifetime to 14 minutes (840,000 ms) so HikariCP recycles connections before the token expires. Each new connection will call the password supplier if you use a custom DataSource factory — or set a scheduled task to rebuild the pool every 14 minutes for simpler setups.
7. Monitoring with CloudWatch Metrics
RDS Proxy publishes a rich set of CloudWatch metrics under the AWS/RDS namespace with a ProxyName dimension. Set up a dashboard and alarms on the following key metrics:
| Metric | Description | Alarm Threshold |
|---|---|---|
| DatabaseConnections | Backend connections from proxy to DB | Alert at 85% of max_connections |
| ClientConnections | Active connections from clients to proxy | Alert if flat-lines at max (indicates queuing) |
| DatabaseConnectionsCurrentlySessionPinned | Connections pinned to a single client | Alert if >20% of DatabaseConnections |
| QueryDuration | Time from query receipt to response | Alert at p99 > 500ms for OLTP |
| TransactionsDuration | Total transaction duration including network | Alert at p99 > 1s |
| ConnectionBorrowTimeout | Clients that waited too long for a connection | Alert at >0 (any timeout is a problem) |
CloudWatch Alarm via CLI
# Alarm when connection borrow timeouts occur (indicates proxy is full)
aws cloudwatch put-metric-alarm \
--alarm-name "rds-proxy-borrow-timeout" \
--alarm-description "RDS Proxy clients timing out waiting for connections" \
--metric-name "ConnectionBorrowTimeout" \
--namespace "AWS/RDS" \
--dimensions Name=ProxyName,Value=myapp-proxy \
--statistic Sum \
--period 60 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:123456789012:ops-alerts
# Alarm when pinning ratio is high (indicates app code issues)
aws cloudwatch put-metric-alarm \
--alarm-name "rds-proxy-high-pinning" \
--alarm-description "High connection pinning — review app for SET statements" \
--metric-name "DatabaseConnectionsCurrentlySessionPinned" \
--namespace "AWS/RDS" \
--dimensions Name=ProxyName,Value=myapp-proxy \
--statistic Average \
--period 300 \
--threshold 10 \
--comparison-operator GreaterThanThreshold \
--evaluation-periods 3 \
--alarm-actions arn:aws:sns:us-east-1:123456789012:ops-alerts
CloudWatch Insights Query for Pinning Analysis
-- RDS Proxy logs are in /aws/rds/proxy/myapp-proxy
-- Use CloudWatch Logs Insights to find what triggers pinning:
fields @timestamp, @message
| filter @message like /pinning/
| parse @message "* reason=*" as ts, reason
| stats count(*) as pinning_events by reason
| sort pinning_events desc
| limit 20
8. Cost Analysis — When It Pays for Itself
RDS Proxy pricing is straightforward: you pay per vCPU of the underlying RDS or Aurora instance. As of 2026:
- Aurora: ~$0.015 per vCPU per hour (Aurora is cheaper because the proxy shares the cluster's compute)
- Standard RDS: ~$0.015 per vCPU per hour on top of the instance cost
Cost Calculation Example
Scenario: db.r6g.2xlarge Aurora PostgreSQL (8 vCPUs)
RDS Proxy cost: 8 vCPUs × $0.015 × 730 hours/month = $87.60/month
What you save:
- Lambda invocations dropping from 500ms to 20ms (470ms saved) due to no connection overhead
→ 10M invocations/month × 0.47s × 512MB = 2,408 GB-seconds saved
→ At $0.0000166667 per GB-second = $40.14/month saved in Lambda costs alone
- Fewer "too many connections" errors = no retry amplification (cascading failures cost $$)
- Faster failover reduces downtime cost
Break-even: High-traffic Lambda workloads break even within weeks.
When NOT to Use RDS Proxy
- Long-running batch jobs: A batch ETL that holds one connection open for 30 minutes gains nothing from a proxy. The cost is pure overhead.
- Single-server applications: A traditional monolith running on one EC2 instance with a HikariCP pool of 20 connections does not need a proxy. The connection pool already handles this.
- Read-only replicas: RDS Proxy does not work with Aurora read replicas as targets — it connects to the writer endpoint. For read replica load balancing, use Aurora's reader endpoint or a custom resolver.
9. Common Pitfalls and Pinning Causes
Understanding pinning is the difference between a proxy that helps you and one that gives you false confidence. Here are the most common production pitfalls:
Pitfall 1: SET Statements Pin Connections
Many ORMs (SQLAlchemy, Hibernate, ActiveRecord) issue SET statements when a connection is acquired — setting timezone, statement timeout, search_path, etc. Each SET pins the connection.
# BAD: This pins the connection to this client
with conn.cursor() as cur:
cur.execute("SET search_path TO myschema, public")
cur.execute("SELECT * FROM users LIMIT 10")
# GOOD: Use connection string parameter instead of SET
# Add ?options=-c search_path=myschema,public to JDBC URL
# Or configure schema at the database user level:
# ALTER ROLE appuser SET search_path TO myschema, public;
Pitfall 2: Using Temporary Tables
Temporary tables in PostgreSQL are session-scoped. If your application creates a TEMP TABLE, the proxy correctly pins the connection until that session ends. Avoid temp tables in Lambda/container workloads — use CTEs (WITH clauses) instead.
-- BAD: Creates a pinned connection
CREATE TEMP TABLE order_staging AS
SELECT * FROM orders WHERE status = 'pending';
-- GOOD: CTE does the same without pinning
WITH order_staging AS (
SELECT * FROM orders WHERE status = 'pending'
)
SELECT * FROM order_staging WHERE total > 100;
Pitfall 3: Long Transactions Hold Backend Connections
A transaction that is started but not committed or rolled back holds a backend connection pinned for its entire duration. Short-living transactions (typical for web/API workloads) are fine. Long transactions degrade proxy efficiency.
# BAD: transaction open while doing slow external API call
conn.autocommit = False
cur.execute("INSERT INTO audit_log (event) VALUES (%s)", (event,))
result = slow_external_api_call() # 2 seconds — connection is pinned entire time
conn.commit()
# GOOD: Minimise time inside transaction
result = slow_external_api_call() # Do slow work BEFORE opening transaction
conn.autocommit = False
cur.execute("INSERT INTO audit_log (event, result) VALUES (%s, %s)", (event, result))
conn.commit()
Pitfall 4: Security Group Misconfiguration
The proxy SG must allow outbound to the DB SG on the DB port. The DB SG must allow inbound from the proxy SG. Application SG must allow outbound to proxy SG, and proxy SG must allow inbound from application SG. Three security group pairs — missing any one means silent connection timeouts.
# Verify proxy can reach the database
aws rds describe-db-proxy-targets \
--db-proxy-name myapp-proxy \
--query 'Targets[*].{Status:TargetHealth.State,Reason:TargetHealth.Reason}'
# Expected output: [{"Status": "AVAILABLE", "Reason": null}]
# If Status is UNAVAILABLE, check security group rules
Pitfall 5: Forgetting TLS is Required
RDS Proxy always requires TLS — you cannot connect without SSL, regardless of your database's rds.force_ssl setting. If your application connects without SSL, it will fail with a confusing error. Always pass sslmode=require (minimum) or sslmode=verify-full (recommended).
Frequently Asked Questions
Does RDS Proxy work with Aurora Serverless v2?
Yes, and this is one of the most powerful combinations in AWS. Aurora Serverless v2 scales capacity in seconds (0.5 ACU steps), and RDS Proxy ensures that scale events do not cause connection storms. The proxy maintains the connection pool while Aurora's compute adjusts. The cost of RDS Proxy is included in Aurora Serverless pricing.
Can I use RDS Proxy with read replicas?
RDS Proxy targets the writer instance or writer endpoint of a cluster. It cannot directly load-balance across read replicas. To spread read traffic, create a separate proxy targeting the Aurora reader endpoint, or use Aurora's built-in cluster reader endpoint which round-robins across read replicas.
What is the maximum number of connections RDS Proxy supports?
Client-side connections to the proxy are limited by the proxy's compute capacity, which scales automatically with the underlying DB instance size. For db.r6g.2xlarge (8 vCPUs), you can sustain tens of thousands of simultaneous client connections. The bottleneck is always the backend database's max_connections, which the proxy's pool size is capped to via the max_connections_percent setting.
How does RDS Proxy handle Multi-AZ failover?
During a Multi-AZ failover, RDS Proxy detects the primary failure and reconnects its backend connection pool to the new primary. Client connections to the proxy endpoint see a brief pause (typically under 30 seconds — compared to 60–120 seconds without a proxy) but do not need to reconnect. The proxy endpoint DNS never changes. Applications with proper retry logic on database errors will be completely transparent to the failover.
Is RDS Proxy compatible with all PostgreSQL and MySQL features?
Almost all. The main incompatibilities are features that require persistent session state: LISTEN/NOTIFY (PostgreSQL pub/sub), pg_advisory_lock, and LOAD DATA LOCAL INFILE (MySQL). These are session-pinning scenarios that effectively defeat multiplexing. For workloads that rely heavily on these features, keep a separate direct connection alongside your proxy connection.