Photo by Homa Appliances on Unsplash
An idle DMS replication instance is one with no replication tasks, or only tasks that are stopped, failed or finished. It still bills every hour. Find them with DescribeReplicationInstances and DescribeReplicationTasks, group tasks by ReplicationInstanceArn, check each task’s last start and stop dates, and confirm with the CPUUtilization metric in the AWS/DMS namespace.
AWS Database Migration Service (DMS) runs migration tasks on replication instances: managed servers you size and pay for by the hour. A migration project typically creates one, runs a full load and maybe some change data capture, and then moves on. The task stops. The instance doesn’t, because the DMS API has no action to stop a replication instance; you can only delete it.
This example is for database and platform engineers cleaning up after migrations. The script finds every idle DMS replication instance in the Regions you pass, lists its tasks and when they last ran, its CPU, and its monthly cost. By default it only reports. With --apply it deletes instances that have zero tasks, the one case DMS allows, and never touches a task.
What does an idle DMS replication instance cost?
You pay for the instance hours and for storage above what’s included. As of September 2026, the AWS Price List shows these on-demand rates in US East (N. Virginia):
| Instance class | Single-AZ per hour | Multi-AZ per hour | Single-AZ per 730-hour month |
|---|---|---|---|
| dms.t3.medium | $0.0745 | $0.149 | $54.39 |
| dms.t3.large | $0.1489 | $0.2978 | $108.70 |
| dms.c5.xlarge | $0.238 | $0.476 | $173.74 |
| dms.r5.large | $0.176 | $0.353 | $128.48 |
| dms.r5.xlarge | $0.353 | $0.706 | $257.69 |
| gp2 storage | $0.115 per GB-month | $0.23 per GB-month | – |
The AWS DMS pricing page says each T2 and T3 instance includes 50 GB of gp2 storage and each C4, R4 and R5 instance includes 100 GB. The script uses those allowances and counts all storage as billable for other classes, so its figure is an upper bound there. Data transfer into DMS is free.
Worked example: a dms.c5.xlarge left behind by a finished Oracle migration, Single-AZ with 200 GB of storage, costs $0.238 × 730 = $173.74 in instance hours plus 200 GB × $0.115 = $23.00, or $196.74 a month. A Multi-AZ dms.r5.large that nobody uses costs $0.353 × 730 = $257.69, before storage above 100 GB.
When is a replication instance idle?
A task’s Status tells you what it’s doing. running, starting, stopping, testing, modifying, moving and creating mean the instance is busy. stopped, failed, failed-move and ready mean it isn’t. A full-load task that completed ends as stopped with the stop reason Stop Reason FULL_LOAD_ONLY_FINISHED.
The script labels an instance:
- IDLE: no tasks when nothing is assigned to it. DMS lets you delete it straight away.
- IDLE: all tasks stopped when every task is parked and the newest start or stop date is older than
--days(default 14). DMS won’t delete it until the tasks are deleted or moved. - recently stopped when tasks are parked but ran within the window. A CDC task stopped for maintenance looks like this.
CPU is shown for context, not used for the verdict. A CDC task on a quiet source database can run for days at low CPU while doing exactly its job, so task status is the better signal.
DMS Serverless: serverless replications are defined by a replication config instead of an instance, and AWS provisions capacity when a replication starts. They don’t appear in DescribeReplicationInstances, so this script doesn’t cover them.
What does the script do?
- Lists every task once
paginateDescribeReplicationTaskswithWithoutSettings: true, which drops the large settings document, grouped byReplicationInstanceArn. - Lists instances
paginateDescribeReplicationInstancesfor class, Multi-AZ, allocated storage and status. - Finds the last activityThe newest of each task’s
StopDate,StartDate, start date and creation date, or the instance’s creation time if it has no tasks. - Reads CPU
GetMetricDataforCPUUtilizationwith theReplicationInstanceIdentifierdimension, hourly average and maximum. - Prices itInstance hours plus billable gp2 storage for a 730-hour month.
- Deletes only with
--applyDeleteReplicationInstancefor instances with zero tasks in theavailablestate.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-database-migration-serviceand@aws-sdk/client-cloudwatch. - An AWS profile the SDK can resolve, set up as in the guide to AWS SDK v3 credential providers: fromIni, fromSSO and assume role. Use a read-only one for the report.
- The Regions where you ran migrations. If you’re not sure, the script to get last month’s AWS cost broken down by service shows whether DMS is on the bill.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadDms",
"Effect": "Allow",
"Action": [
"dms:DescribeReplicationInstances",
"dms:DescribeReplicationTasks",
"cloudwatch:GetMetricData"
],
"Resource": "*"
},
{
"Sid": "DeleteEmptyInstancesWithApply",
"Effect": "Allow",
"Action": "dms:DeleteReplicationInstance",
"Resource": "arn:aws:dms:*:123456789012:rep:*"
}
]
}
Replace the account ID and drop the second statement for a report-only role. The IAM policy generator for TypeScript code lists the actions if you extend the script.
The script to find idle DMS replication instances
// find-idle-dms-replication-instances.ts
// Lists AWS DMS replication instances with their tasks, last task activity, CPU and monthly cost,
// and flags instances with no tasks or only stopped, failed or finished tasks.
// Report only by default. --apply deletes instances that have zero tasks; it never deletes a task.
// Usage: npx tsx find-idle-dms-replication-instances.ts [--regions us-east-1,eu-west-1] [--days 14] [--csv dms.csv] [--apply]
import { writeFileSync } from "node:fs";
import {
DatabaseMigrationServiceClient,
DeleteReplicationInstanceCommand,
paginateDescribeReplicationInstances,
paginateDescribeReplicationTasks,
type ReplicationTask,
} from "@aws-sdk/client-database-migration-service";
import { CloudWatchClient, paginateGetMetricData } from "@aws-sdk/client-cloudwatch";
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 regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Number(flag("--days") ?? 14);
const csvPath = flag("--csv");
const apply = args.includes("--apply");
// us-east-1 on-demand USD per hour: [Single-AZ, Multi-AZ]. AWS Price List, September 2026.
const PRICE: Record<string, [number, number]> = {
"dms.t3.micro": [0.0186, 0.0372],
"dms.t3.small": [0.0372, 0.0744],
"dms.t3.medium": [0.0745, 0.149],
"dms.t3.large": [0.1489, 0.2978],
"dms.c5.large": [0.119, 0.238],
"dms.c5.xlarge": [0.238, 0.476],
"dms.c5.2xlarge": [0.476, 0.952],
"dms.c6i.large": [0.119, 0.238],
"dms.r5.large": [0.176, 0.353],
"dms.r5.xlarge": [0.353, 0.706],
"dms.r5.2xlarge": [0.706, 1.411],
"dms.r6i.large": [0.176, 0.353],
};
const GP2_PER_GB_MONTH: [number, number] = [0.115, 0.23]; // Single-AZ, Multi-AZ
const HOURS_PER_MONTH = 730;
// Busy task states: anything that isn't parked. "ready" means created but never started or reset.
const ACTIVE = new Set(["running", "starting", "stopping", "testing", "modifying", "moving", "creating"]);
/** GB of gp2 storage the pricing page says is included: 50 GB for T2/T3, 100 GB for C4/R4/R5. Other classes: 0 (upper bound). */
function includedStorage(cls: string): number {
if (/^dms\.t[23]\./.test(cls)) return 50;
if (/^dms\.(c4|r4|r5)\./.test(cls)) return 100;
return 0;
}
interface Row {
Region: string;
Instance: string;
Class: string;
MultiAZ: boolean;
StorageGB: number;
Tasks: string;
LastActivity: string;
IdleDays: number | string;
CpuAvg: number | string;
CpuMax: number | string;
PerMonth: string;
Verdict: string;
Action: string;
}
function lastActivity(tasks: ReplicationTask[]): Date | undefined {
const dates = tasks.flatMap((t) => [
t.ReplicationTaskStats?.StopDate, t.ReplicationTaskStats?.StartDate, t.ReplicationTaskStartDate, t.ReplicationTaskCreationDate,
]).filter((d): d is Date => d instanceof Date);
return dates.length ? new Date(Math.max(...dates.map((d) => d.getTime()))) : undefined;
}
async function cpu(cw: CloudWatchClient, identifier: string): Promise<{ avg: number; max: number } | undefined> {
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
const metric = { Namespace: "AWS/DMS", MetricName: "CPUUtilization", Dimensions: [{ Name: "ReplicationInstanceIdentifier", Value: identifier }] };
const values: Record<string, number[]> = { avg: [], max: [] };
for await (const page of paginateGetMetricData({ client: cw }, {
StartTime: start,
EndTime: end,
MetricDataQueries: [
{ Id: "avg", MetricStat: { Metric: metric, Period: 3600, Stat: "Average" } },
{ Id: "max", MetricStat: { Metric: metric, Period: 3600, Stat: "Maximum" } },
],
})) {
for (const r of page.MetricDataResults ?? []) values[r.Id ?? ""]?.push(...(r.Values ?? []));
}
const avg = values.avg ?? [];
const max = values.max ?? [];
if (avg.length === 0) return undefined;
return { avg: Math.round((avg.reduce((a, b) => a + b, 0) / avg.length) * 10) / 10, max: Math.round(Math.max(...max) * 10) / 10 };
}
async function scanRegion(region: string): Promise<Row[]> {
const dms = new DatabaseMigrationServiceClient({ region });
const cw = new CloudWatchClient({ region });
// One listing of every task in the Region, grouped by the instance it runs on.
const tasksByInstance = new Map<string, ReplicationTask[]>();
for await (const page of paginateDescribeReplicationTasks({ client: dms }, { WithoutSettings: true })) {
for (const t of page.ReplicationTasks ?? []) {
const arn = t.ReplicationInstanceArn ?? "";
tasksByInstance.set(arn, [...(tasksByInstance.get(arn) ?? []), t]);
}
}
const rows: Row[] = [];
for await (const page of paginateDescribeReplicationInstances({ client: dms }, {})) {
for (const ri of page.ReplicationInstances ?? []) {
const arn = ri.ReplicationInstanceArn ?? "";
const id = ri.ReplicationInstanceIdentifier ?? "";
const cls = ri.ReplicationInstanceClass ?? "";
const multiAz = ri.MultiAZ ?? false;
const storage = ri.AllocatedStorage ?? 0;
const tasks = tasksByInstance.get(arn) ?? [];
const busy = tasks.filter((t) => ACTIVE.has(t.Status ?? ""));
const last = lastActivity(tasks) ?? ri.InstanceCreateTime;
const idleDays = last ? Math.floor((Date.now() - last.getTime()) / 86_400_000) : "-";
const stats = await cpu(cw, id);
const price = PRICE[cls];
const extraGb = Math.max(0, storage - includedStorage(cls));
const perMonth = price
? `$${(Math.round((price[multiAz ? 1 : 0] * HOURS_PER_MONTH + extraGb * GP2_PER_GB_MONTH[multiAz ? 1 : 0]) * 100 + 1e-6) / 100).toFixed(2)}`
: "unpriced class";
let verdict = "in use";
if (tasks.length === 0) verdict = "IDLE: no tasks";
else if (busy.length === 0 && typeof idleDays === "number" && idleDays >= days) verdict = "IDLE: all tasks stopped";
else if (busy.length === 0) verdict = "recently stopped: re-check later";
let action = verdict === "IDLE: no tasks" ? "would delete (--apply)" : "-";
if (verdict.startsWith("IDLE") && tasks.length > 0) action = "delete or move the tasks first";
if (apply && verdict === "IDLE: no tasks") {
if (ri.ReplicationInstanceStatus !== "available") {
action = `skipped: status ${ri.ReplicationInstanceStatus ?? "unknown"}`;
} else {
try {
await dms.send(new DeleteReplicationInstanceCommand({ ReplicationInstanceArn: arn }));
action = "deleting";
} catch (err) {
action = `failed: ${err instanceof Error ? err.name : String(err)}`;
}
}
}
rows.push({
Region: region,
Instance: id,
Class: cls,
MultiAZ: multiAz,
StorageGB: storage,
Tasks: tasks.length === 0 ? "none" : tasks.map((t) => `${t.ReplicationTaskIdentifier}: ${t.Status}`).join("; "),
LastActivity: last ? last.toISOString().slice(0, 10) : "-",
IdleDays: idleDays,
CpuAvg: stats ? stats.avg : "-",
CpuMax: stats ? stats.max : "-",
PerMonth: perMonth,
Verdict: verdict,
Action: action,
});
}
}
return rows;
}
function toCsv(rows: Row[]): string {
const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
const cell = (v: string | number | boolean) => `"${String(v).replace(/"/g, '""')}"`;
return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}
async function main(): Promise<void> {
if (!Number.isInteger(days) || days < 1 || days > 455) throw new Error("--days must be a whole number from 1 to 455");
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 === 0) {
console.log(`No DMS replication instances in ${regions.join(", ")}.`);
return;
}
console.table(rows);
const idle = rows.filter((r) => r.Verdict.startsWith("IDLE"));
const monthly = idle.reduce((s, r) => s + (Number(r.PerMonth.replace("$", "")) || 0), 0);
console.log(`${idle.length} of ${rows.length} replication instances idle for ${days}+ days: about $${monthly.toFixed(2)} a month (us-east-1 on-demand prices).`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-database-migration-service @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node
# Report only, two Regions, CSV for the database team
AWS_PROFILE=readonly npx tsx find-idle-dms-replication-instances.ts --regions us-east-1,eu-west-1 --csv idle-dms.csv
# Delete instances that have no tasks at all
AWS_PROFILE=dms-admin npx tsx find-idle-dms-replication-instances.ts --regions us-east-1 --apply
Sample output
┌─────────┬─────────────┬───────────────────────────┬─────────────────┬─────────┬───────────┬─────────────────────────────────────────────────────┬──────────────┬──────────┬────────┬────────┬───────────┬────────────────────────────────────┬──────────────────────────────────┐
│ (index) │ Region │ Instance │ Class │ MultiAZ │ StorageGB │ Tasks │ LastActivity │ IdleDays │ CpuAvg │ CpuMax │ PerMonth │ Verdict │ Action │
├─────────┼─────────────┼───────────────────────────┼─────────────────┼─────────┼───────────┼─────────────────────────────────────────────────────┼──────────────┼──────────┼────────┼────────┼───────────┼────────────────────────────────────┼──────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'orders-cdc' │ 'dms.r5.large' │ true │ 100 │ 'orders-to-redshift: running' │ '2026-05-31' │ 120 │ 18.4 │ 61.2 │ '$257.69' │ 'in use' │ '-' │
│ 1 │ 'us-east-1' │ 'legacy-oracle-migration' │ 'dms.c5.xlarge' │ false │ 200 │ 'full-load-crm: stopped; full-load-billing: failed' │ '2026-06-23' │ 97 │ 2.1 │ 6.3 │ '$196.74' │ 'IDLE: all tasks stopped' │ 'delete or move the tasks first' │
│ 2 │ 'us-east-1' │ 'poc-mysql-to-aurora' │ 'dms.t3.medium' │ false │ 50 │ 'none' │ '2026-07-15' │ 75 │ 1.2 │ 3.9 │ '$54.39' │ 'IDLE: no tasks' │ 'would delete (--apply)' │
│ 3 │ 'us-east-1' │ 'reporting-sync' │ 'dms.r5.xlarge' │ false │ 100 │ 'reports-cdc: stopped' │ '2026-09-23' │ 5 │ 4.8 │ 22.5 │ '$257.69' │ 'recently stopped: re-check later' │ '-' │
└─────────┴─────────────┴───────────────────────────┴─────────────────┴─────────┴───────────┴─────────────────────────────────────────────────────┴──────────────┴──────────┴────────┴────────┴───────────┴────────────────────────────────────┴──────────────────────────────────┘
2 of 4 replication instances idle for 14+ days: about $251.13 a month (us-east-1 on-demand prices).
Names and numbers are illustrative. poc-mysql-to-aurora has no tasks and would be deleted with --apply. legacy-oracle-migration finished its full load months ago and has one failed task; it costs almost $200 a month, but DMS requires deleting or moving both tasks first. reporting-sync stopped five days ago, so it’s left alone until the next run.
Delete, keep or switch to DMS Serverless?
| Situation | What to do |
|---|---|
| No tasks | Delete the instance. Its endpoints and subnet group stay, ready for the next migration. |
| Finished full-load tasks | Save the task settings and table mappings if you’ll need them again, delete the tasks, then the instance. |
| CDC task stopped for good | Confirm the target is now the system of record, then delete the task and instance. |
| Migrations that run now and then | Recreate the instance when needed, or use DMS Serverless, which bills for capacity you use per hour. |
A finished migration often leaves more than a replication instance. The scripts to find idle RDS instances with no connections and find and delete old RDS manual snapshots catch the source database and the snapshots taken before cutover. For a target that went to a warehouse, see find idle Amazon Redshift clusters. If the migration streamed changes to a Kinesis target, the script to find idle Kinesis Data Streams catches the stream that stayed behind.
Troubleshooting
failed: InvalidResourceStateFaultin the Action column. The instance isn’t in a state that allows deletion, for example a task was added between the listing and the delete. Re-run the report.- CpuAvg shows “-“. No
CPUUtilizationdata in the window, usually because the instance was just created or isn’tavailable. - PerMonth shows “unpriced class”. Add the class to
PRICEwith its Single-AZ and Multi-AZ rates. Prices vary by Region. - An access-denied error in one Region. Check SCPs for that Region; the steps to troubleshoot AWS IAM access denied errors explain how to read the message.
Ask ChatWithCloud instead
For a one-off check, ask ChatWithCloud “Which DMS replication instances have no running tasks, and when did their tasks last stop?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and explains the result; see how ChatWithCloud turns a question into AWS SDK calls. It uses one profile and Region per session and runs changes without asking, so connect ChatWithCloud to a read-only AWS profile and delete an idle DMS replication instance yourself once the owner agrees. The AWS practical examples hub has more cleanup scripts, and an AWS budget alert created with SDK v3 catches the next forgotten instance sooner.
Frequently asked questions
Can you stop a DMS replication instance to save money?
No. You can stop tasks, but the DMS API has no action to stop a replication instance. It bills until you delete it.
Why can’t I delete my DMS replication instance?
The DMS API reference says you must delete the migration tasks associated with a replication instance before you can delete it. Delete or move the tasks, then delete the instance.
Does deleting a replication instance delete the endpoints?
No. Source and target endpoints are separate resources. They stay and can be reused by a new instance or a DMS Serverless replication.
How do I know a DMS full load has finished?
The task’s status is stopped with the stop reason Stop Reason FULL_LOAD_ONLY_FINISHED, and FullLoadProgressPercent in ReplicationTaskStats is 100.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud