AWS RDS Proxy: Connection Pooling for Serverless and Microservices (2026)

AWS RDS Proxy — Connection Pooling for Databases

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.

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.
Real-world scenario: An e-commerce site runs order processing on Lambda. On Black Friday, 2,000 Lambda functions fire concurrently. Each calls 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.

Pinning triggers to avoid:
  • Using SET statements to modify session variables (e.g., SET search_path in PostgreSQL)
  • Using temporary tables or temporary sequences
  • Calling stored procedures that use cursors
  • Using the ROLLBACK TO SAVEPOINT with nested transactions
  • Using MySQL LOCK TABLES or GET_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:

  1. An RDS or Aurora database in a VPC
  2. A Secrets Manager secret containing the database username and password
  3. An IAM role that grants RDS Proxy permission to read that secret
  4. 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
}
Key Terraform parameters:
  • max_connections_percent = 90 — RDS Proxy will use up to 90% of the database's max_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 send SET variable 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

ScenarioLambda ConcurrencyDB Connections UsedRisk
No proxy, connection in handler500500Exhaustion at max_connections
No proxy, connection outside handler500 (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 Proxy3,000~45None — 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
Key insight for containers: Because RDS Proxy handles connection pooling at the infrastructure level, you can set your application-level connection pool size much smaller (3–5 connections per pod/task instead of 20–50). This reduces per-pod memory usage and lets you scale to more replicas without hitting DB connection limits.

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());
    }
}
Token refresh with HikariCP: IAM tokens expire after 15 minutes. Set 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:

MetricDescriptionAlarm Threshold
DatabaseConnectionsBackend connections from proxy to DBAlert at 85% of max_connections
ClientConnectionsActive connections from clients to proxyAlert if flat-lines at max (indicates queuing)
DatabaseConnectionsCurrentlySessionPinnedConnections pinned to a single clientAlert if >20% of DatabaseConnections
QueryDurationTime from query receipt to responseAlert at p99 > 500ms for OLTP
TransactionsDurationTotal transaction duration including networkAlert at p99 > 1s
ConnectionBorrowTimeoutClients that waited too long for a connectionAlert 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.
RDS Proxy is free for Aurora Serverless v2. When you use Aurora Serverless v2, the proxy cost is included. This makes the Lambda + Aurora Serverless v2 + RDS Proxy stack extremely cost-effective at variable workloads — you pay only for the ACUs Aurora actually uses plus proxy overhead already included.

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.