Get EC2 Right-Sizing Recommendations From Compute Optimizer

A tall server rack with rows of blinking blue status lights in a dark data centre

Photo by Bernard Hermant on Unsplash

Compute Optimizer EC2 recommendations come from GetEC2InstanceRecommendations: for each analyzed instance it returns a finding (Overprovisioned, Underprovisioned or Optimized), the reason codes, and up to three ranked instance types with a performance risk from 0 to 4 and an estimated monthly saving. Your account must be opted in first, and each instance needs at least 30 hours of CloudWatch metrics in the last 14 days.

AWS Compute Optimizer already does the right-sizing maths for your EC2 fleet; most teams just never look at it outside the console. This example is for platform and FinOps engineers who want Compute Optimizer EC2 recommendations as a sorted, scriptable report: which instances are too big, what to change them to, what it saves, and which changes need more testing than a reboot.

The script checks your enrollment, reads recommendations in every Region you name, keeps the top-ranked option per instance and prints a table and optional CSV. It never resizes anything; the only change it can make is opting the account in when you pass --apply. If you’d rather measure CPU yourself, the DIY script to detect underutilized EC2 instances from the CPU metric works without Compute Optimizer.

How do Compute Optimizer EC2 recommendations work?

Compute Optimizer analyzes CloudWatch utilization metrics over a look-back period and classifies each instance. The API reference defines the findings this way:

Field What it tells you
finding Overprovisioned when at least one resource (CPU, memory, network, disk) can be sized down and none is short; Underprovisioned when at least one doesn’t meet the workload’s needs; Optimized otherwise.
findingReasonCodes Which resource drove it, such as CPUOverprovisioned, MemoryUnderprovisioned or EBSIOPSOverprovisioned.
recommendationOptions[].performanceRisk 0 to 4. 0 means the option is predicted to always have enough capacity; higher means validate before moving.
migrationEffort VeryLow when the CPU architecture stays the same, up to Medium or High for a move to AWS Graviton.
platformDifferences Hypervisor, NetworkInterface, StorageInterface, InstanceStoreAvailability, VirtualizationType or Architecture: things that can break on the new type.
savingsOpportunity / savingsOpportunityAfterDiscounts Estimated monthly saving at On-Demand prices, and the same after your Savings Plans and Reserved Instance discounts.

Two requirements trip people up. The Compute Optimizer resource requirements say an instance needs at least 30 hours of CloudWatch metric data in the past 14 days, and that analysis can take up to 24 hours after that. Memory is only analyzed when the unified CloudWatch agent publishes it (on Linux, mem_used_percent in the CWAgent namespace); without it, a memory-hungry instance can be flagged as over-provisioned on CPU alone. The same page says Cost Explorer must be enabled for savings and pricing to be filled in.

What do Compute Optimizer recommendations cost?

AWS’s Compute Optimizer pricing page says there’s no additional charge for the service itself; you pay for your resources and CloudWatch as usual. The paid option is enhanced infrastructure metrics, which stretches the look-back period from 14 to up to 93 days. As of September 2026, the AWS Price List for Compute Optimizer (published 11 September 2026) shows it at $0.0003360215 per instance-hour in US East (N. Virginia).

Worked example: 40 instances with enhanced infrastructure metrics for a 31-day month cost 40 × 744 × $0.0003360215 = $10.00. That’s worth it for workloads with monthly peaks, such as month-end batch jobs, that a 14-day window can miss. Compare it with the savings column before switching it on everywhere.

What does the script do?

  1. Checks enrollmentGetEnrollmentStatus. If the account isn’t Active, it stops, or with --apply calls UpdateEnrollmentStatus with status: "Active".
  2. Reads recommendations per RegionGetEC2InstanceRecommendations with a Finding filter (default Overprovisioned,Underprovisioned), following nextToken by hand because the SDK has no paginator for this call.
  3. Picks the top optionSorts recommendationOptions by rank and keeps rank 1.
  4. Uses the discounted savingPrefers savingsOpportunityAfterDiscounts, falling back to the On-Demand estimate, and labels which one it used.
  5. ReportsSorts by saving, prints a table, totals the over-provisioned savings and counts rows that need extra testing (risk 3 or more, or any platform difference). --csv writes every column.

It also prints the per-instance errors the API returns, for example for an instance family Compute Optimizer doesn’t support, so a missing instance isn’t silently ignored.

Prerequisites

  • Node.js 18 or later with tsx, and @aws-sdk/client-compute-optimizer.
  • Instances that have run long enough to meet the 30-hour requirement, and ideally the CloudWatch agent for memory metrics.
  • Cost Explorer enabled, so the savings fields aren’t empty. The script to check Savings Plans coverage shows what those discounts already cover.
  • A read-only profile for the report, and a separate one if you want --apply to opt in.

Which IAM permissions does it need?

compute-optimizer-ec2-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadEc2Recommendations",
      "Effect": "Allow",
      "Action": [
        "compute-optimizer:GetEnrollmentStatus",
        "compute-optimizer:GetEC2InstanceRecommendations",
        "ec2:DescribeInstances"
      ],
      "Resource": "*"
    },
    {
      "Sid": "OptInOnlyWithApply",
      "Effect": "Allow",
      "Action": "compute-optimizer:UpdateEnrollmentStatus",
      "Resource": "*"
    },
    {
      "Sid": "CreateServiceLinkedRoleOnOptIn",
      "Effect": "Allow",
      "Action": "iam:CreateServiceLinkedRole",
      "Resource": "arn:aws:iam::*:role/aws-service-role/compute-optimizer.amazonaws.com/AWSServiceRoleForComputeOptimizer*",
      "Condition": { "StringLike": { "iam:AWSServiceName": "compute-optimizer.amazonaws.com" } }
    },
    {
      "Sid": "ConfigureServiceLinkedRole",
      "Effect": "Allow",
      "Action": "iam:PutRolePolicy",
      "Resource": "arn:aws:iam::*:role/aws-service-role/compute-optimizer.amazonaws.com/AWSServiceRoleForComputeOptimizer"
    }
  ]
}

The Compute Optimizer User Guide says viewing EC2 recommendations also needs ec2:DescribeInstances, and that opting in creates a service-linked role, which is why the last three statements exist. Drop them for a report-only role. If you extend the script, the IAM policy generator for TypeScript AWS SDK code lists the actions your calls need.

The script to get Compute Optimizer EC2 recommendations

get-ec2-rightsizing-recommendations.ts

// get-ec2-rightsizing-recommendations.ts
// Reads AWS Compute Optimizer EC2 instance recommendations in one or more Regions and prints the
// over- and under-provisioned instances with the top-ranked alternative, its performance risk and the
// estimated monthly savings. Report only by default. --apply opts the account in to Compute Optimizer
// (UpdateEnrollmentStatus) when it isn't enrolled yet; it never changes an instance.
// Usage: npx tsx get-ec2-rightsizing-recommendations.ts [--regions us-east-1,eu-west-1]
//        [--findings Overprovisioned,Underprovisioned] [--csv rightsizing.csv] [--apply]
import { writeFileSync } from "node:fs";
import {
  ComputeOptimizerClient,
  GetEC2InstanceRecommendationsCommand,
  GetEnrollmentStatusCommand,
  UpdateEnrollmentStatusCommand,
  type InstanceRecommendation,
  type InstanceRecommendationOption,
} from "@aws-sdk/client-compute-optimizer";

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 findings = (flag("--findings") ?? "Overprovisioned,Underprovisioned").split(",").map((f) => f.trim()).filter(Boolean);
const csvPath = flag("--csv");
const apply = args.includes("--apply");

interface Row {
  Region: string;
  Instance: string;
  Name: string;
  Finding: string;
  Reasons: string;
  Current: string;
  Suggested: string;
  Risk: string;
  Migration: string;
  Differences: string;
  SavingPerMonth: number;
  Basis: string;
  LookBackDays: number;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

/** The rank-1 option, or the first option when no rank is present. */
function topOption(rec: InstanceRecommendation): InstanceRecommendationOption | undefined {
  const options = [...(rec.recommendationOptions ?? [])];
  options.sort((a, b) => (a.rank ?? 99) - (b.rank ?? 99));
  return options[0];
}

/** Savings after Savings Plans and RI discounts when Compute Optimizer has them, otherwise On-Demand based. */
function saving(option: InstanceRecommendationOption | undefined): { value: number; basis: string } {
  const after = option?.savingsOpportunityAfterDiscounts?.estimatedMonthlySavings?.value;
  if (after !== undefined) return { value: after, basis: "after discounts" };
  const onDemand = option?.savingsOpportunity?.estimatedMonthlySavings?.value;
  return { value: onDemand ?? 0, basis: onDemand !== undefined ? "on-demand" : "none" };
}

async function ensureEnrolled(client: ComputeOptimizerClient): Promise<boolean> {
  const status = await client.send(new GetEnrollmentStatusCommand({}));
  if (status.status === "Active") return true;
  console.log(`Compute Optimizer enrollment status: ${status.status ?? "unknown"} ${status.statusReason ?? ""}`.trim());
  if (!apply) {
    console.log("Re-run with --apply to opt this account in. Recommendations can take up to 24 hours to appear.");
    return false;
  }
  const out = await client.send(new UpdateEnrollmentStatusCommand({ status: "Active" }));
  console.log(`Opt-in requested: status is now ${out.status ?? "unknown"}. Run the report again tomorrow.`);
  return false;
}

async function scanRegion(region: string): Promise<Row[]> {
  const client = new ComputeOptimizerClient({ region });
  const rows: Row[] = [];
  let nextToken: string | undefined;
  do {
    const page = await client.send(new GetEC2InstanceRecommendationsCommand({
      filters: [{ name: "Finding", values: findings }],
      maxResults: 100,
      nextToken,
    }));
    for (const e of page.errors ?? []) console.error(`${region}: ${e.identifier ?? ""} ${e.code ?? ""} ${e.message ?? ""}`.trim());
    for (const rec of page.instanceRecommendations ?? []) {
      const option = topOption(rec);
      const { value, basis } = saving(option);
      rows.push({
        Region: region,
        Instance: rec.instanceArn?.split("/").pop() ?? "",
        Name: rec.instanceName ?? "",
        Finding: rec.finding ?? "",
        Reasons: (rec.findingReasonCodes ?? []).map((c) => c.replace(/provisioned$/, "")).join(" "),
        Current: rec.currentInstanceType ?? "",
        Suggested: option?.instanceType ?? "",
        Risk: option?.performanceRisk !== undefined ? option.performanceRisk.toFixed(1) : "",
        Migration: option?.migrationEffort ?? "",
        Differences: (option?.platformDifferences ?? []).join(" ") || "-",
        SavingPerMonth: Math.round(value * 100) / 100,
        Basis: basis,
        LookBackDays: Math.round(rec.lookBackPeriodInDays ?? 0),
      });
    }
    nextToken = page.nextToken;
  } while (nextToken);
  return rows;
}

function toCsv(rows: Row[]): string {
  const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
  const cell = (v: string | number) => `"${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 (!(await ensureEnrolled(new ComputeOptimizerClient({ region: regions[0] })))) return;

  const rows: Row[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: ${errorText(err)}`);
    }
  }
  if (rows.length === 0) {
    console.log(`No ${findings.join(" or ")} EC2 instances in ${regions.join(", ")}.`);
    return;
  }
  rows.sort((a, b) => b.SavingPerMonth - a.SavingPerMonth);
  console.table(rows.map(({ Basis, LookBackDays, ...shown }) => shown));

  const over = rows.filter((r) => r.Finding === "Overprovisioned");
  const total = over.reduce((sum, r) => sum + r.SavingPerMonth, 0);
  const risky = over.filter((r) => Number(r.Risk) >= 3 || r.Differences !== "-").length;
  console.log(`${over.length} over-provisioned instances could save about $${total.toFixed(2)} a month ` +
    `(${risky} need extra testing: performance risk 3+ or platform differences).`);
  console.log(`${rows.length - over.length} under-provisioned instances may need a larger type.`);
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
}

main().catch((err) => {
  console.error(errorText(err));
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-compute-optimizer
npm install --save-dev tsx typescript @types/node

# Over- and under-provisioned instances in two Regions, saved to CSV
AWS_PROFILE=readonly npx tsx get-ec2-rightsizing-recommendations.ts --regions us-east-1,eu-west-1 --csv rightsizing.csv

# Only under-provisioned instances
AWS_PROFILE=readonly npx tsx get-ec2-rightsizing-recommendations.ts --findings Underprovisioned

# Opt the account in if it isn't enrolled yet
AWS_PROFILE=finops-admin npx tsx get-ec2-rightsizing-recommendations.ts --apply

Sample output

Output

┌─────────┬─────────────┬───────────────────────┬─────────────────┬────────────────────┬──────────────────────┬──────────────┬───────────────┬───────┬───────────┬───────────────────────────────┬────────────────┐
│ (index) │ Region      │ Instance              │ Name            │ Finding            │ Reasons              │ Current      │ Suggested     │ Risk  │ Migration │ Differences                   │ SavingPerMonth │
├─────────┼─────────────┼───────────────────────┼─────────────────┼────────────────────┼──────────────────────┼──────────────┼───────────────┼───────┼───────────┼───────────────────────────────┼────────────────┤
│ 0       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60002' │ 'batch-worker'  │ 'Overprovisioned'  │ 'CPUOver'            │ 'c5.4xlarge' │ 'c7g.2xlarge' │ '2.0' │ 'Medium'  │ 'Architecture'                │ 314.63         │
│ 1       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60001' │ 'reporting-api' │ 'Overprovisioned'  │ 'CPUOver MemoryOver' │ 'm5.2xlarge' │ 'm5.xlarge'   │ '1.0' │ 'VeryLow' │ '-'                           │ 112.13         │
│ 2       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60003' │ 'legacy-cms'    │ 'Overprovisioned'  │ 'CPUOver'            │ 't2.large'   │ 't3.medium'   │ '3.0' │ 'VeryLow' │ 'Hypervisor NetworkInterface' │ 37.23          │
│ 3       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60004' │ 'search-node'   │ 'Underprovisioned' │ 'MemoryUnder'        │ 'r5.large'   │ 'r5.xlarge'   │ '0.0' │ 'VeryLow' │ '-'                           │ 0              │
└─────────┴─────────────┴───────────────────────┴─────────────────┴────────────────────┴──────────────────────┴──────────────┴───────────────┴───────┴───────────┴───────────────────────────────┴────────────────┘
3 over-provisioned instances could save about $463.99 a month (2 need extra testing: performance risk 3+ or platform differences).
1 under-provisioned instances may need a larger type.

The run used mocked Compute Optimizer responses, so IDs and amounts are illustrative. reporting-api is the easy win: same family, risk 1, no platform differences, a stop-and-start change. batch-worker saves the most but moves to Graviton (Architecture), so its software and AMI must support arm64. legacy-cms moves from a Xen-based t2 to a Nitro t3, where the ENA and NVMe drivers matter, and its risk of 3 says to test under load first.

How do you act on a recommendation safely?

  • Start with risk 0 to 1 and no platform differences. These are usually a stop, ModifyInstanceAttribute to the new type, and start.
  • Check memory was measured. If MemoryOverprovisioned is absent on a memory-heavy host, install the CloudWatch agent and wait for a fresh recommendation.
  • Look at commitments first. Downsizing an instance covered by a Reserved Instance can leave the reservation idle; the script to find Reserved Instances about to expire shows when that stops mattering. On a Dedicated Host, a smaller instance saves nothing until the host itself is freed; the script to find idle EC2 Dedicated Hosts shows how full each host is.
  • Consider the generation, not just the size. The script to find previous-generation EC2 instances covers t2, m4 and friends that Compute Optimizer may call optimized.
  • Watch the bill after the change. Asking AI why your AWS bill changed is a quick way to confirm the saving landed.

Troubleshooting

  • OptInRequiredException. The account isn’t opted in. Run with --apply, then wait: analysis can take up to 24 hours.
  • No rows, but you know instances are idle. They may have fewer than 30 hours of metrics in 14 days, be stopped, or be in a Region you didn’t pass. Stopped instances are better handled by the script to find long-stopped EC2 instances.
  • Saving shows 0 (basis none in the CSV). Cost Explorer isn’t enabled, or the row is under-provisioned and has no saving to report.
  • AccessDeniedException. Check both the Compute Optimizer and EC2 actions above, then follow how to troubleshoot AWS IAM access denied errors.
  • Member accounts are missing. From an organization’s management account, pass accountIds with one member account per request; the script as written reads only the calling account.

Ask ChatWithCloud instead

For a quick answer, ask ChatWithCloud “Which EC2 instances does Compute Optimizer say are over-provisioned, and what would each save?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result; how ChatWithCloud runs AWS questions locally explains the loop. It uses one profile and Region per session, can be wrong, and runs changes without a confirmation step, so connect ChatWithCloud with a read-only AWS profile and keep resizing in your own change process. More cost reports are in the AWS practical examples library.

Frequently asked questions

Is AWS Compute Optimizer free?

The standard recommendations have no additional charge. Enhanced infrastructure metrics, which extends the look-back to up to 93 days, cost $0.0003360215 per resource-hour in US East (N. Virginia) as of September 2026, about $0.25 per instance a month.

How long until Compute Optimizer shows EC2 recommendations?

An instance needs at least 30 hours of CloudWatch metrics in the past 14 days, and AWS says analysis can take up to 24 hours once the data is there.

Why does Compute Optimizer ignore memory usage?

EC2 doesn’t publish memory metrics by default. Compute Optimizer only analyzes memory when the unified CloudWatch agent sends it, for example mem_used_percent on Linux.

What does performance risk 3 mean?

On the 0 to 4 scale, higher values mean the recommended type is more likely to fall short on at least one resource. Test the workload on the new type before switching in production.

Does Compute Optimizer resize instances for me?

No. It only recommends. You change the instance type yourself, which needs a stop and start for EBS-backed instances.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud