Photo by Aleksandr Lyaptsev on Unsplash
RDS Multi-AZ is not enabled when a DB instance returns MultiAZ: false from DescribeDBInstances. Aurora is different: its high availability comes from reader instances in other Availability Zones, so check each cluster’s members and their AvailabilityZone. Filter by an env tag to focus on production, then convert with ModifyDBInstance and MultiAZ: true.
A single-AZ database is one hardware fault or one Availability Zone outage away from downtime, and nothing in the console warns you about it once the database is running. Development copies are single-AZ on purpose. The problem is the production database that started as a quick test and never got the second AZ.
This example gives you a TypeScript script for the AWS SDK for JavaScript v3 that finds every database with RDS Multi-AZ not enabled, including Aurora clusters that look healthy but run all their instances in one zone. With --names and --apply, it schedules the conversion for the instances you name. It sits next to the audit to find RDS instances without automated backups, which covers recovery rather than availability.
Which RDS deployments count as Multi-AZ?
RDS has three deployment shapes, and only one of them uses the MultiAZ flag the way most people expect.
| Deployment | How AWS describes it | How the script checks it |
|---|---|---|
| Multi-AZ DB instance | A synchronous standby in a different AZ. The standby can’t serve reads | MultiAZ on the DB instance |
| Multi-AZ DB cluster | A writer and two reader instances in three separate AZs, for MySQL and PostgreSQL | Skipped: multi-AZ by design |
| Aurora DB cluster | Storage is always copied across AZs; instances fail over to Aurora Replicas | Number of members and the AZ of each |
The Aurora case catches people out. The cluster volume survives an AZ failure, but compute doesn’t. AWS documents that a cluster with no Aurora Replicas recreates its primary in the same AZ after a failure, typically in less than 10 minutes, while promoting a replica is typically done in less than 60 seconds. If the writer and every reader share one AZ, an AZ outage means you create new instances by hand.
What does the script do?
- Reads every DB instance
paginateDescribeDBInstancesrecords each instance’s Availability Zone and flags standalone instances and read replicas withMultiAZfalse. A queued conversion shows asMulti-AZ pending. - Checks Aurora clusters
paginateDescribeDBClustersreadsDBClusterMembers, maps each member to its AZ and flags clusters with no reader or with all instances in one AZ. - Filters by environment
--env prodkeeps databases whoseenv,environmentorstagetag matches, so development databases don’t bury the list. - Converts named instances with
--applyModifyDBInstancewithMultiAZ: trueandApplyImmediately: false, only for identifiers in--names. Aurora rows are never changed; the fix there is a new reader in another AZ.
Prerequisites
- Node.js 18 or later, npm,
tsxand@aws-sdk/client-rds. - An environment tag on your databases. If tagging is patchy, the script to find untagged AWS resources with the Tagging API shows the gaps.
- A read-only profile for the report and a separate profile with
rds:ModifyDBInstancefor--apply.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadDatabases",
"Effect": "Allow",
"Action": [
"rds:DescribeDBInstances",
"rds:DescribeDBClusters"
],
"Resource": "*"
},
{
"Sid": "ConvertWithApply",
"Effect": "Allow",
"Action": "rds:ModifyDBInstance",
"Resource": "arn:aws:rds:*:123456789012:db:*"
}
]
}
Drop the second statement for an audit-only role. rds:ModifyDBInstance can change far more than Multi-AZ, so keep it for database owners; the guide to review a generated IAM policy for least privilege covers tag-based conditions that narrow it further.
The script to find RDS databases with Multi-AZ not enabled
// find-single-az-rds.ts
// Lists RDS DB instances that aren't Multi-AZ and Aurora clusters whose instances all sit in one
// Availability Zone. Report only unless you pass --apply with --names, which converts the named
// DB instances to Multi-AZ in their next maintenance window.
// Usage:
// npx tsx find-single-az-rds.ts [--regions us-east-1,eu-west-1] [--env prod] [--names db1,db2 --apply]
import { ModifyDBInstanceCommand, RDSClient, paginateDescribeDBClusters, paginateDescribeDBInstances, type Tag } from "@aws-sdk/client-rds";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const list = (v: string | undefined) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const regions = list(flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1");
const envFilter = flag("--env")?.toLowerCase(); // only report databases whose env/environment/stage tag matches
const names = new Set(list(flag("--names")));
const apply = args.includes("--apply");
interface Row {
Region: string;
Kind: "instance" | "replica" | "aurora";
Name: string;
Engine: string;
Class: string;
Env: string;
AZs: string;
Finding: string;
Result: string;
}
const envTag = (tags: Tag[] | undefined) =>
tags?.find((t) => ["env", "environment", "stage"].includes((t.Key ?? "").toLowerCase()))?.Value ?? "";
async function scanRegion(region: string): Promise<Row[]> {
const rds = new RDSClient({ region });
const rows: Row[] = [];
const azOf = new Map<string, string>(); // DB instance identifier -> Availability Zone
const classOf = new Map<string, string>();
for await (const page of paginateDescribeDBInstances({ client: rds }, {})) {
for (const db of page.DBInstances ?? []) {
const id = db.DBInstanceIdentifier ?? "?";
azOf.set(id, db.AvailabilityZone ?? "?");
classOf.set(id, db.DBInstanceClass ?? "?");
if (db.DBClusterIdentifier || db.MultiAZ) continue; // cluster members are judged per cluster below
const replica = Boolean(db.ReadReplicaSourceDBInstanceIdentifier || db.ReadReplicaSourceDBClusterIdentifier);
rows.push({
Region: region,
Kind: replica ? "replica" : "instance",
Name: id,
Engine: db.Engine ?? "?",
Class: db.DBInstanceClass ?? "?",
Env: envTag(db.TagList),
AZs: db.AvailabilityZone ?? "?",
Finding: db.PendingModifiedValues?.MultiAZ ? "Single-AZ (Multi-AZ pending)" : "Single-AZ",
Result: "",
});
}
}
// Aurora: availability comes from reader instances in other AZs, not from a MultiAZ flag you set.
// Multi-AZ DB clusters (non-Aurora) always run three instances in three AZs, so they're skipped.
for await (const page of paginateDescribeDBClusters({ client: rds }, {})) {
for (const c of page.DBClusters ?? []) {
if (!(c.Engine ?? "").startsWith("aurora") || c.EngineMode === "serverless") continue;
const members = c.DBClusterMembers ?? [];
const azs = new Set(members.map((m) => azOf.get(m.DBInstanceIdentifier ?? "") ?? "?"));
let finding = "";
if (members.length === 0) finding = "No DB instances";
else if (members.length === 1) finding = "Writer only, no reader";
else if (azs.size === 1) finding = "All instances in one AZ";
if (!finding) continue;
const writer = members.find((m) => m.IsClusterWriter)?.DBInstanceIdentifier ?? "";
rows.push({
Region: region,
Kind: "aurora",
Name: c.DBClusterIdentifier ?? "?",
Engine: c.Engine ?? "?",
Class: classOf.get(writer) ?? "?",
Env: envTag(c.TagList),
AZs: [...azs].join(" "),
Finding: finding,
Result: "",
});
}
}
const matched = envFilter ? rows.filter((r) => r.Env.toLowerCase() === envFilter) : rows;
if (!apply) return matched;
for (const row of matched) {
if (!names.has(row.Name)) {
row.Result = "skipped (not in --names)";
continue;
}
if (row.Kind === "aurora") {
row.Result = "add a reader in another AZ";
continue;
}
try {
// ApplyImmediately false: RDS converts the instance in its next maintenance window.
await rds.send(new ModifyDBInstanceCommand({ DBInstanceIdentifier: row.Name, MultiAZ: true, ApplyImmediately: false }));
row.Result = "Multi-AZ scheduled";
} catch (err) {
row.Result = `error: ${err instanceof Error ? err.name : String(err)}`;
}
}
return matched;
}
async function main(): Promise<void> {
if (apply && names.size === 0) {
console.error("--apply needs --names with the DB instance identifiers to convert.");
process.exit(1);
}
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
}
}
if (rows.length) console.table(rows);
console.log(`${rows.length} single-AZ databases in ${regions.join(", ")}${envFilter ? ` tagged ${envFilter}` : ""}.`);
if (!apply) console.log("Report only. Re-run with --names and --apply to schedule Multi-AZ for named instances.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-rds
npm install --save-dev tsx typescript @types/node
# Report production databases in two Regions
AWS_PROFILE=readonly npx tsx find-single-az-rds.ts --regions us-east-1,eu-west-1 --env prod
# Schedule Multi-AZ for one named instance in its next maintenance window
AWS_PROFILE=rds-admin npx tsx find-single-az-rds.ts --regions us-east-1 --env prod --names payments-pg --apply
Sample output
┌─────────┬─────────────┬────────────┬─────────────────┬─────────────────────┬─────────────────┬────────┬──────────────┬────────────────────────────────┬────────────────────────────┐
│ (index) │ Region │ Kind │ Name │ Engine │ Class │ Env │ AZs │ Finding │ Result │
├─────────┼─────────────┼────────────┼─────────────────┼─────────────────────┼─────────────────┼────────┼──────────────┼────────────────────────────────┼────────────────────────────┤
│ 0 │ 'us-east-1' │ 'instance' │ 'payments-pg' │ 'postgres' │ 'db.m6g.large' │ 'prod' │ 'us-east-1a' │ 'Single-AZ' │ 'Multi-AZ scheduled' │
│ 1 │ 'us-east-1' │ 'instance' │ 'reports-mysql' │ 'mysql' │ 'db.r6g.large' │ 'prod' │ 'us-east-1b' │ 'Single-AZ (Multi-AZ pending)' │ 'skipped (not in --names)' │
│ 2 │ 'us-east-1' │ 'aurora' │ 'orders-aurora' │ 'aurora-postgresql' │ 'db.r6g.xlarge' │ 'prod' │ 'us-east-1a' │ 'All instances in one AZ' │ 'skipped (not in --names)' │
│ 3 │ 'us-east-1' │ 'aurora' │ 'search-aurora' │ 'aurora-mysql' │ 'db.r6g.large' │ 'prod' │ 'us-east-1b' │ 'Writer only, no reader' │ 'skipped (not in --names)' │
└─────────┴─────────────┴────────────┴─────────────────┴─────────────────────┴─────────────────┴────────┴──────────────┴────────────────────────────────┴────────────────────────────┘
4 single-AZ databases in us-east-1 tagged prod.
Names are illustrative. payments-pg will be converted in its next maintenance window. reports-mysql already has a conversion queued. orders-aurora has a reader, but it sits in the same AZ as the writer, and search-aurora has no reader at all.
What does Multi-AZ cost?
A Multi-AZ DB instance bills the instance hours and storage twice. These are on-demand prices for US East (N. Virginia) from the AWS Price List API, as of September 2026; check the Amazon RDS for PostgreSQL pricing page for your Region and engine.
| Item (us-east-1) | Single-AZ | Multi-AZ |
|---|---|---|
| db.m6g.large, PostgreSQL | $0.159 per hour | $0.318 per hour |
| db.r6g.large, MySQL | $0.215 per hour | $0.430 per hour |
| General Purpose gp3 storage | $0.115 per GB-month | $0.230 per GB-month |
Worked example: payments-pg, a db.m6g.large PostgreSQL instance with 100 GB of gp3, over a 730-hour month.
- Single-AZ: 730 × $0.159 = $116.07 for compute, plus 100 × $0.115 = $11.50 for storage, so $127.57 a month.
- Multi-AZ: 730 × $0.318 = $232.14, plus 100 × $0.230 = $23.00, so $255.14 a month.
- The standby adds $127.57 a month, exactly double in this case. Backups, I/O and data transfer aren’t included.
For Aurora, the fix is one more instance: a reader in another AZ bills like any other Aurora instance of its class. If the bill jumps after a change like this, ask AI why your AWS bill increased to see which line moved.
Is converting a live database safe?
AWS documents that the conversion takes a snapshot of the primary’s EBS volumes, builds the standby from it and turns on synchronous replication. It avoids downtime, but the new volumes load in the background and synchronous replication can raise write latency, so AWS recommends against converting a write-sensitive production instance in place. Their suggested route is to create a read replica, turn on backups, convert the replica to Multi-AZ, let its volumes warm up and then promote it.
That is why --apply needs --names and sends ApplyImmediately: false: the change waits for the maintenance window instead of starting in the middle of the day. RDS emits event RDS-EVENT-0025 when the conversion is complete. Other work queued for the same window shows up in the report to check RDS auto minor version upgrade and pending maintenance.
Availability isn’t the only production safeguard. Check the same databases with the scripts to find RDS databases without deletion protection, to find resources not protected by AWS Backup and to find RDS databases that don’t require SSL/TLS.
Troubleshooting
InvalidDBInstanceStateFaulton--apply. The instance is modifying, stopped or otherwise busy. Retry once it’savailable.- A database is missing from the
--env prodreport. Its tag value isn’t exactlyprod. Run without--envand read the Env column. - A SQL Server instance won’t convert. SQL Server uses Database Mirroring or Always On Availability Groups for Multi-AZ, and AWS lists supported versions separately. Check the SQL Server Multi-AZ page in the RDS User Guide.
- The database is also on an old engine version. Upgrading during the same window may be cheaper than paying twice for Extended Support; the script to find RDS and Aurora databases on extended support lists them.
Ask ChatWithCloud instead
To see the list without writing code, ask ChatWithCloud “Which production RDS databases in us-east-1 aren’t Multi-AZ?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and explains the answer; how ChatWithCloud answers AWS questions shows each step. It runs generated code without a confirmation step, so use it with a read-only profile, as the guide to connect ChatWithCloud to your AWS account explains, and make the conversion with the script.
Frequently asked questions
How do I check if my RDS instance is Multi-AZ?
Run aws rds describe-db-instances --query "DBInstances[].[DBInstanceIdentifier,MultiAZ]". For Aurora, list the cluster members and check that at least two of them are in different Availability Zones.
Does enabling Multi-AZ on RDS cause downtime?
AWS documents that converting a Single-AZ instance to Multi-AZ avoids downtime but can affect performance while the standby is built, especially for write-heavy workloads.
Does Multi-AZ double the RDS cost?
For a Multi-AZ DB instance, instance hours and storage are billed at roughly twice the Single-AZ rate. In US East (N. Virginia), a db.m6g.large PostgreSQL instance costs $0.159 per hour Single-AZ and $0.318 per hour Multi-AZ, as of September 2026.
Is Aurora always Multi-AZ?
Aurora always stores data across AZs. Compute is only highly available across AZs when the cluster has at least one reader instance in a different AZ from the writer.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud