Find Single-AZ RDS Databases in Production

Two parallel rows of server racks in a data center with redundant power cabling overhead

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?

  1. Reads every DB instancepaginateDescribeDBInstances records each instance’s Availability Zone and flags standalone instances and read replicas with MultiAZ false. A queued conversion shows as Multi-AZ pending.
  2. Checks Aurora clusterspaginateDescribeDBClusters reads DBClusterMembers, maps each member to its AZ and flags clusters with no reader or with all instances in one AZ.
  3. Filters by environment--env prod keeps databases whose env, environment or stage tag matches, so development databases don’t bury the list.
  4. Converts named instances with --applyModifyDBInstance with MultiAZ: true and ApplyImmediately: 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, tsx and @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:ModifyDBInstance for --apply.

Which IAM permissions does it need?

rds-multi-az-policy.json

{
  "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

// 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?

Terminal

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

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

  • InvalidDBInstanceStateFault on --apply. The instance is modifying, stopped or otherwise busy. Retry once it’s available.
  • A database is missing from the --env prod report. Its tag value isn’t exactly prod. Run without --env and 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