This website uses cookies

Read our Privacy policy and Terms of use for more information.

What it actually is

RDS is a managed operations team for a database engine you already know. You pick MySQL, MariaDB, PostgreSQL, Oracle, SQL Server or Db2, and AWS runs the host, the operating system, the storage volume, the backups, the patching and the failover machinery. You still get a normal database endpoint and a normal wire protocol. Your application code does not know RDS exists.

That is also the catch. RDS does not change the database, it changes who gets paged for the parts around it. You do not get the superuser role, you do not get a shell on the host, and you do not get to install arbitrary extensions or tune the kernel. In exchange, nobody on your team has to write a backup script, practice a restore at 3 a.m., or build a failover runbook. The docs describe a service. The honest description is a trade: operational control for operational time.

What AWS operates: the host, OS patching, storage, automated backups, replication to a standby, and the failover decision. What you operate: the schema, the queries, the instance class, the storage settings, the parameter group, network access, and your application's behavior when the endpoint changes underneath it. That last item is the one people skip, and it is the subject of the lab below.

The model

The unit is the DB instance: one engine, one instance class (for example db.t4g.micro), one storage volume, one DNS endpoint. Storage is EBS-backed and you choose the type. AWS recommends gp3 for general purpose, where storage, IOPS and throughput are provisioned independently, and io2 Block Express for provisioned IOPS. Magnetic storage is deprecated and cannot be used for new instances.

For gp3 the docs give a baseline that depends on size. On MySQL, MariaDB, PostgreSQL and Db2, volumes from 20 to 399 GiB get 3,000 IOPS and 125 MiB/s, and volumes of 400 GiB and up get 12,000 IOPS and 500 MiB/s, because larger volumes are striped across four underlying volumes. That detail has a practical consequence: growing a 300 GiB volume to 400 GiB quadruples the baseline IOPS, which is sometimes cheaper than buying IOPS on the smaller volume.

Availability comes in three shapes. Single-AZ is one instance in one Availability Zone. A Multi-AZ DB instance deployment adds one synchronous standby replica in another AZ. The docs say the standby cannot serve read traffic; it exists only to take over. A Multi-AZ DB cluster, a different product, has two standbys that can serve reads. Read replicas are a fourth thing entirely: asynchronous copies you use to scale reads, which do not make the primary highly available by themselves.

Failover works through DNS. When the primary fails, RDS promotes the standby and updates the DNS record for the instance endpoint to point to it. The endpoint name never changes, but the address behind it does, and any client that cached the old address or kept an open connection has to notice and reconnect. For MariaDB, MySQL, Oracle and PostgreSQL the failover mechanism is AWS's own technology; SQL Server uses database mirroring or Always On availability groups.

The docs list the reasons RDS will fail over on its own: an offline OS patch, an unhealthy primary host, loss of network connectivity, an unresponsive primary, a storage volume failure, a modification you made, and a failover you requested yourself. Each shows up in the event log with a fixed message. Read the list once. Two of those reasons, patching and modifications, are failovers you will schedule, not suffer.

When to use it, when not to

The honest comparison is against the three things people confuse it with.

Option

Pick it when

Skip it when

RDS (Multi-AZ instance)

You need a standard engine, standard SQL, and the lowest operational load

You need OS access, unsupported extensions, or custom engine builds

Aurora

You need faster failover, up to 15 readers on shared storage, or storage that grows without planning

The workload is small and steady and the simpler engine is cheaper

DynamoDB

Access patterns are known key lookups and you want no instances at all

You need joins, ad hoc queries and transactions across arbitrary tables

PostgreSQL on EC2

You need superuser, a custom extension, or a setup RDS forbids

You are doing it only to save money, because the labor costs more

Aurora is the closest relative and has its own article in this series (21/120). Short version: RDS is the engine on EBS with a standby copy; Aurora replaces the storage layer. If the main reason you are considering Aurora is "it fails over faster", measure your RDS failover first, because that number is what the rest of this article produces.

RDS is the right default when the data is relational, the team is small, and nobody wants to own database operations. It is the wrong choice when the reason for leaving a managed service is control, because RDS is built on removing exactly that.

What it costs

RDS bills along several independent dimensions, and the surprise is usually the sum of the quiet ones. The pricing page lists DB instance hours (billed per second, with a 10-minute minimum after a billable change), provisioned storage, provisioned IOPS, backup storage, data transfer, and RDS Extended Support, which is charged per vCPU-hour for engine versions past end of standard support.

Multi-AZ doubles the instance and storage lines, because you are paying for a second copy of both. The pricing page's own io1 example for US East (N. Virginia) shows it: a Single-AZ dedicated log volume example comes to $428 a month, and the Multi-AZ example with one standby comes to $856. Data transfer between AZs for the Multi-AZ replication itself is not charged.

Three traps. First, burstable instances (T4g and T3) charge $0.075 per vCPU-hour in CPU credits when average CPU exceeds the baseline over a rolling 24 hours, and Reserved Instances do not cover that charge. A database that looks cheap on a t4g and runs hot all day is not cheap. Second, a stopped instance still bills for storage and backups. Third, Extended Support: an old engine version quietly adds a per-vCPU-hour line to your bill.

On the free tier: the free tier page lists RDS as available on both the free plan and the paid plan of the new account structure (up to $200 in credits, free plan accounts close after six months or when credits run out), but it does not state RDS-specific hours or storage there. The RDS pricing page still describes the older shape of 750 hours of Single-AZ usage, 20 GB of storage and 20 GB of backup storage. I could not reconcile the two from the pages, so check what your account actually has before assuming the lab is free. A Multi-AZ lab is not Single-AZ anyway.

I could not get the per-hour rate tables for db.t4g.micro or gp3 storage to render from the AWS pricing pages, so I will not quote them. Use the AWS Pricing Calculator for exact numbers.

The limits that bite

The default quotas per account per Region: 40 DB instances (20 in ap-south-1), 15 read replicas per source, 100 manual snapshots, 50 parameter groups, 50 subnet groups, and 100,000 GB of total storage across all instances. All are adjustable. Not adjustable: 20 subnets per subnet group.

Maximum storage per instance is 64 TiB for MariaDB, MySQL, PostgreSQL and Db2, and up to 256 TiB for Oracle and SQL Server using additional volumes. Small burstable classes cap lower: a db.t4g.micro is limited to 6 TiB on MySQL, MariaDB and PostgreSQL. SQL Server instances other than io2 Block Express top out at 16 TiB.

Connection limits are engine parameters derived from memory. For PostgreSQL the default max_connections is the lesser of DBInstanceClassMemory divided by 9,531,392 and 5,000. On a 1 GiB instance that works out to roughly a hundred connections, and a connection pool you did not size will reach it first. This is the most common RDS outage that is not an AWS outage.

The other limit that bites is the failover itself. The failover documentation says times are typically 60 to 120 seconds and that large transactions or lengthy recovery can make it longer. That is a number you have to plan an application around, not a rounding error.

Build it

We will create a small Multi-AZ PostgreSQL instance, point a connection prober at it, force a failover with the API, and measure the gap. Nothing here is exotic. The value is in seeing your own number instead of the documentation's.

Prerequisites. An AWS account with a default VPC (the script uses it), Python 3.10+, boto3, and psycopg2. The prober must run inside the VPC because the instance is not publicly accessible. AWS CloudShell with a VPC environment, or a small EC2 instance in the same VPC, both work. The docs require a DB subnet group covering at least two Availability Zones, and the default VPC has that.

IAM. The principal needs RDS actions for create, describe, reboot and delete of DB instances, create and delete of DB subnet groups, and describe events. It needs EC2 permissions to describe VPCs and subnets and to create and delete a security group and add an ingress rule. We ask RDS to manage the master password in Secrets Manager, so it also needs permission to read that secret. The first RDS use in an account creates a service-linked role, which needs iam:CreateServiceLinkedRole. For a throwaway lab account, the AWS managed policy AmazonRDSFullAccess plus EC2 security group permissions and secret read is the quick route; scope it down for anything shared.

Step 1: network and instance. This creates a subnet group, a security group that allows PostgreSQL only from inside the VPC, and the instance.

import boto3

rds = boto3.client("rds")
ec2 = boto3.client("ec2")

vpc = ec2.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])["Vpcs"][0]
subnets = ec2.describe_subnets(
    Filters=[{"Name": "vpc-id", "Values": [vpc["VpcId"]]}]
)["Subnets"]

rds.create_db_subnet_group(
    DBSubnetGroupName="sl-rds-lab",
    DBSubnetGroupDescription="Software Letters RDS lab",
    SubnetIds=[s["SubnetId"] for s in subnets],
)

sg = ec2.create_security_group(
    GroupName="sl-rds-lab", Description="Software Letters RDS lab", VpcId=vpc["VpcId"]
)["GroupId"]
ec2.authorize_security_group_ingress(
    GroupId=sg,
    IpPermissions=[{
        "IpProtocol": "tcp", "FromPort": 5432, "ToPort": 5432,
        "IpRanges": [{"CidrIp": vpc["CidrBlock"]}],
    }],
)

rds.create_db_instance(
    DBInstanceIdentifier="sl-rds-lab",
    Engine="postgres",
    DBInstanceClass="db.t4g.micro",
    AllocatedStorage=20,
    StorageType="gp3",
    StorageEncrypted=True,
    MasterUsername="labadmin",
    ManageMasterUserPassword=True,
    MultiAZ=True,
    DBSubnetGroupName="sl-rds-lab",
    VpcSecurityGroupIds=[sg],
    PubliclyAccessible=False,
    BackupRetentionPeriod=1,
)

rds.get_waiter("db_instance_available").wait(DBInstanceIdentifier="sl-rds-lab")
db = rds.describe_db_instances(DBInstanceIdentifier="sl-rds-lab")["DBInstances"][0]
print(db["Endpoint"]["Address"])
print("primary AZ:", db["AvailabilityZone"], "standby AZ:", db["SecondaryAvailabilityZone"])
print("secret:", db["MasterUserSecret"]["SecretArn"])

Creation takes several minutes. When it finishes you have an endpoint, a primary AZ, a standby AZ and a secret ARN. We deliberately do not pin an engine version, so RDS picks the current default for PostgreSQL. The password never appears in your code or shell history because Secrets Manager holds it.

Step 2: the prober. This is the actual experiment. It resolves the endpoint, opens a fresh connection, runs a trivial query, and records the result every half second. A fresh connection each time matters: a long-lived connection hides the outage behind a broken socket, while a new connection measures what a reconnecting client experiences.

import json, socket, sys, time
import boto3, psycopg2

rds = boto3.client("rds")
sm = boto3.client("secretsmanager")

db = rds.describe_db_instances(DBInstanceIdentifier="sl-rds-lab")["DBInstances"][0]
host = db["Endpoint"]["Address"]
secret = json.loads(
    sm.get_secret_value(SecretId=db["MasterUserSecret"]["SecretArn"])["SecretString"]
)

last_state, down_since, outages = None, None, []
while True:
    now = time.time()
    try:
        ip = socket.gethostbyname(host)
        conn = psycopg2.connect(
            host=host, dbname="postgres", user=secret["username"],
            password=secret["password"], connect_timeout=2, sslmode="require",
        )
        conn.cursor().execute("select 1")
        conn.close()
        state = f"UP   {ip}"
        if down_since:
            outages.append(now - down_since)
            print(f"outage lasted {now - down_since:.1f}s")
            down_since = None
    except Exception as exc:
        state = f"DOWN {type(exc).__name__}"
        down_since = down_since or now
    if state != last_state:
        print(time.strftime("%H:%M:%S"), state)
        last_state = state
    time.sleep(0.5)

Install the driver with pip install psycopg2-binary if it is missing. Start this in one terminal and leave it running.

Step 3: trigger the failover. In a second terminal, ask RDS to reboot with failover. The API parameter is ForceFailover, and the CLI equivalent is --force-failover.

import boto3
rds = boto3.client("rds")
before = rds.describe_db_instances(DBInstanceIdentifier="sl-rds-lab")["DBInstances"][0]
print("before:", before["AvailabilityZone"], "->", before["SecondaryAvailabilityZone"])
rds.reboot_db_instance(DBInstanceIdentifier="sl-rds-lab", ForceFailover=True)

The docs warn that this interrupts the database abruptly, that sessions may not shut down gracefully, and that you should stop transactions first to avoid data loss. On a lab instance that is the point. On production, this is the exact command you run during a maintenance window to prove the failover path works.

Step 4: verify. Watch the prober terminal. You should see the state flip from UP to DOWN, then back to UP, and the IP address change between the two, because the DNS record now points at the former standby. The "outage lasted" line is your number. The docs say typical failover is 60 to 120 seconds, so treat anything in that range as normal and anything far outside it as worth investigating.

Then confirm in the control plane. Wait a few minutes, because the AZ change can take that long to show up in the API, and compare the AZs:

after = rds.describe_db_instances(DBInstanceIdentifier="sl-rds-lab")["DBInstances"][0]
print("after:", after["AvailabilityZone"], "->", after["SecondaryAvailabilityZone"])

for e in rds.describe_events(
    SourceIdentifier="sl-rds-lab", SourceType="db-instance", Duration=60
)["Events"]:
    print(e["Date"], e["Message"])

The primary and standby AZs should have swapped, and the event log should contain the "The user requested a failover of the DB instance" message plus the failover-started and completed events. If the prober showed no outage at all, your DNS resolution or connection reuse is masking it, which is itself an interesting finding.

Step 5: make it faster. Run the experiment a second time with a client that behaves better. The best-practices page says to set a DNS TTL below 30 seconds in any client that caches DNS, and the failover page says the JVM TTL should be no more than 60 seconds. It also says the AWS suite of drivers, which watch instance status and topology, cut failover reconnection to single-digit seconds compared with tens of seconds for open-source drivers. Your prober already does a fresh lookup each loop, so the measured time is mostly the engine and DNS. A connection pool in a real app is where the extra minutes hide.

When it breaks

  • Prober shows DOWN for far longer than 120 seconds. Check for a large open transaction or heavy write load. The docs say long recovery extends failover. Provisioning enough I/O and keeping transactions small both shorten recovery.

  • Connections fail after failover but the DB is fine. Your client cached the old address. Lower the DNS TTL, close and re-open connections on error, and test this deliberately. The docs say the RDS SLA expects you to follow these operational guidelines.

  • InvalidParameterCombination or a storage error on create. The instance class and storage combination is not valid for the engine. The minimum for gp3 here is 20 GiB.

  • DBSubnetGroupDoesNotCoverEnoughAZs. The subnet group needs subnets in at least two Availability Zones.

  • Timeouts to the endpoint from your laptop. The instance is not publicly accessible and the security group allows only VPC traffic. That is the secure default. Run the prober inside the VPC.

  • InvalidDBInstanceState on reboot. Failover reboot needs the instance to be available and configured for Multi-AZ.

  • Too many connections. You hit max_connections. Pool connections or use RDS Proxy rather than raising the parameter blindly.

Cleanup

Delete the instance, the subnet group and the security group. Skipping the final snapshot is fine for a lab; for anything real, keep one.

import boto3
rds = boto3.client("rds")
ec2 = boto3.client("ec2")

rds.delete_db_instance(
    DBInstanceIdentifier="sl-rds-lab",
    SkipFinalSnapshot=True,
    DeleteAutomatedBackups=True,
)
rds.get_waiter("db_instance_deleted").wait(DBInstanceIdentifier="sl-rds-lab")
rds.delete_db_subnet_group(DBSubnetGroupName="sl-rds-lab")
sg = ec2.describe_security_groups(Filters=[{"Name": "group-name", "Values": ["sl-rds-lab"]}])
ec2.delete_security_group(GroupId=sg["SecurityGroups"][0]["GroupId"])

Confirm with aws rds describe-db-instances that the list is empty, and check Secrets Manager for the managed secret, which RDS removes with the instance. Check the RDS snapshots page too: no manual snapshot should exist, because you skipped the final one. Then look at the Billing console the next day. Stopped or forgotten instances keep billing for storage and backups.

Cost of following along: a db.t4g.micro in Multi-AZ with 20 GiB gp3 for about an hour should cost well under a dollar, based on public rates I have seen before but could not re-verify on the AWS pricing page this week. The 10-minute minimum applies after billable changes. Use the AWS Pricing Calculator before you start if the number matters. I did not run this exact script against a live account for this article; the calls follow the current API and documentation, so run it in a disposable account first.

The takeaway

Multi-AZ RDS is not a promise that you will not have downtime. It is a promise that the downtime is bounded, automated, and recoverable without a human. The number to carry out of this article is your own measured failover time, because your application's real availability is that time multiplied by how often failovers happen, which includes every patch and every instance modification. Measure it once, set the DNS TTL, use a driver that expects it, and RDS becomes boring in exactly the way a database should be.

Sources