Find Previous-Generation EC2 Instances to Upgrade

Stacked vintage computer components and circuit boards on a workbench

Photo by Lorenzo Herrera on Unsplash

To find previous-generation EC2 instances, call DescribeInstanceTypes in each Region, keep the types whose CurrentGeneration is false, and match them against the InstanceType of every instance from DescribeInstances. The script below does that, suggests a same-size current-generation type and can pull On-Demand prices from the Price List API.

Old instance types don’t break, which is why they linger: an m4.large launched in 2017 still runs today, on older processors and the Xen hypervisor. This example is for engineers and FinOps-minded leads who want to find previous generation EC2 instances across Regions, see what each could move to, and decide which moves are worth the downtime.

You get a read-only TypeScript script for the AWS SDK for JavaScript v3. It sits alongside the other AWS SDK v3 cost and cleanup examples and pairs well with the script that can report EC2 instances by type, launch time and Region, which shows the whole fleet rather than only the outdated part.

Which EC2 instance types count as previous generation?

AWS keeps a list in its specifications for previous generation instances: A1, C1, C3, C4, G3, I2, M1, M2, M3, M4, P3, P3dn, R3, R4 and T1. AWS says it continues to support them but encourages current generation types for the best performance. All of them run on the Xen hypervisor except A1, the first Graviton family, and P3dn, which are Nitro-based.

You don’t need to hard-code that list. DescribeInstanceTypes returns a CurrentGeneration flag for every type offered in the Region, and the API also accepts a current-generation filter. The API text describes the flag as “the latest generation instance type of an instance family”, but in practice it marks the families above: an m5 or r5 still reports true even though newer M and R generations exist. So this script finds the types AWS itself classes as previous generation, not every type that has a successor.

How much does upgrading save?

Not always as much as you’d expect. On-Demand Linux prices in US East (N. Virginia), from the AWS Price List as of September 2026, with a month approximated as 730 hours:

Type vCPU / memory Per hour Per month vs. old
m4.large 2 / 8 GiB $0.1000 $73.00 n/a
m7i.large 2 / 8 GiB $0.1008 $73.58 +0.8%
m7g.large (Graviton) 2 / 8 GiB $0.0816 $59.57 −18.4%
c4.xlarge 4 / 7.5 GiB $0.1990 $145.27 n/a
c7i.xlarge 4 / 8 GiB $0.1785 $130.31 −10.3%
c7g.xlarge (Graviton) 4 / 8 GiB $0.1450 $105.85 −27.1%
r4.large 2 / 15.25 GiB $0.1330 $97.09 n/a
r7i.large 2 / 16 GiB $0.1323 $96.58 −0.5%
r7g.large (Graviton) 2 / 16 GiB $0.1071 $78.18 −19.5%

The arithmetic for one row: c4.xlarge at $0.1990 × 730 = $145.27 a month, c7g.xlarge at $0.1450 × 730 = $105.85, a difference of $39.42 per instance per month. The pattern is clear: moving from M4 or R4 to the Intel 7th generation buys newer processors and more memory for about the same price, while the Graviton equivalents are where the bill drops. If you want the saving without changing architecture, m6i.large is listed at $0.0960, 4% under m4.large. For workloads that tolerate interruption, the script to compare EC2 Spot price history across Availability Zones prices the same types on Spot.

What does the script do?

  1. Loads every instance type in the RegionpaginateDescribeInstanceTypes with MaxResults: 100 (the maximum), keeping vCPUs, memory, architecture, hypervisor and the CurrentGeneration flag.
  2. Finds instances on previous-generation typespaginateDescribeInstances for pending, running, stopping and stopped instances, matched against that map. Stopped instances still count: their EBS volumes are billed, and they come back on the old type.
  3. Suggests same-size current typesFrom a small, editable map (M4 to m7i or m7g, C4 to c7i or c7g, and so on), keeping only suggestions that exist in that Region, and notes an architecture change, less memory or a Xen-to-Nitro move.
  4. Adds prices with --pricesQueries the Price List API GetProducts for the On-Demand Linux price of each old and suggested type. Windows and licensed software are priced differently, so treat the numbers as a comparison.
  5. Reports onlyPrints a table, a count by type and optional CSV. It never stops, resizes or relaunches anything.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • @aws-sdk/client-ec2 and @aws-sdk/client-pricing.
  • A read-only AWS profile for the Regions you scan. Credentials resolve the usual way; the guide to the AWS SDK v3 credentials provider chain explains the order.

Which IAM permissions does it need?

Three read actions, none of which supports resource-level permissions, so they use "Resource": "*". Drop pricing:GetProducts if you never pass --prices.

previous-generation-ec2-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadInstancesAndTypes",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeInstanceTypes"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ReadPriceList",
      "Effect": "Allow",
      "Action": "pricing:GetProducts",
      "Resource": "*"
    }
  ]
}

The IAM policy generator for TypeScript AWS SDK code can draft a policy like this from the script, and the checklist to review a generated IAM policy for least privilege covers what to check before you attach it.

The full script to find previous generation EC2 instances

find-previous-generation-ec2.ts

// find-previous-generation-ec2.ts
// Lists EC2 instances that run on previous-generation instance types (CurrentGeneration = false),
// suggests same-size current-generation types, and optionally compares On-Demand Linux prices.
// Read-only: it never stops, resizes or modifies an instance.
// Usage: npx tsx find-previous-generation-ec2.ts [--regions us-east-1,eu-west-1] [--prices] [--csv prev-gen.csv]
import { writeFileSync } from "node:fs";
import {
  EC2Client,
  paginateDescribeInstances,
  paginateDescribeInstanceTypes,
  type InstanceTypeInfo,
} from "@aws-sdk/client-ec2";
import { PricingClient, GetProductsCommand } from "@aws-sdk/client-pricing";

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 withPrices = args.includes("--prices");
const csvPath = flag("--csv");

// Starting points: previous-generation family -> current-generation families (x86 first, then Graviton).
// Families not listed (G3, P3, P3dn...) are reported with no suggestion. Edit to match your standards.
const UPGRADE: Record<string, string[]> = {
  t1: ["t3", "t4g"],
  m1: ["m7i", "m7g"], m3: ["m7i", "m7g"], m4: ["m7i", "m7g"],
  c1: ["c7i", "c7g"], c3: ["c7i", "c7g"], c4: ["c7i", "c7g"],
  m2: ["r7i", "r7g"], r3: ["r7i", "r7g"], r4: ["r7i", "r7g"],
  a1: ["c7g", "m7g"], i2: ["i4i"],
};

interface Row {
  Region: string;
  Instance: string;
  Name: string;
  State: string;
  Type: string;
  Hypervisor: string;
  Suggested: string;
  Note: string;
  PricePerHour?: string;
  SuggestedPerHour?: string;
}

const gib = (t?: InstanceTypeInfo): number => (t?.MemoryInfo?.SizeInMiB ?? 0) / 1024;
const vcpus = (t?: InstanceTypeInfo): number => t?.VCpuInfo?.DefaultVCpus ?? 0;
const archOf = (t?: InstanceTypeInfo): string => (t?.ProcessorInfo?.SupportedArchitectures ?? []).join("/");

// All instance types offered in the Region, keyed by name, with the current-generation flag.
async function instanceTypes(ec2: EC2Client): Promise<Map<string, InstanceTypeInfo>> {
  const types = new Map<string, InstanceTypeInfo>();
  for await (const page of paginateDescribeInstanceTypes({ client: ec2 }, { MaxResults: 100 })) {
    for (const t of page.InstanceTypes ?? []) if (t.InstanceType) types.set(t.InstanceType, t);
  }
  return types;
}

function suggest(type: string, types: Map<string, InstanceTypeInfo>): { suggested: string[]; note: string } {
  const [family = "", size = ""] = type.split(".");
  const current = types.get(type);
  const candidates = (UPGRADE[family] ?? [])
    .map((f) => types.get(`${f}.${size}`))
    .filter((t): t is InstanceTypeInfo => t !== undefined && t.CurrentGeneration === true);
  if (!candidates.length) return { suggested: [], note: "no same-size suggestion: pick a type manually" };
  const notes: string[] = [];
  for (const c of candidates) {
    const name = c.InstanceType ?? "";
    if (archOf(c) !== archOf(current)) notes.push(`${name} is ${archOf(c)}: needs an ${archOf(c)} AMI`);
    if (gib(c) < gib(current) || vcpus(c) < vcpus(current)) notes.push(`${name} has fewer vCPUs or less memory`);
  }
  if (current?.Hypervisor === "xen") notes.push("Xen to Nitro: check ENA and NVMe drivers first");
  return { suggested: candidates.map((c) => c.InstanceType ?? ""), note: notes.join("; ") };
}

// On-Demand Linux price per hour from the Price List Query API (its endpoint used here is us-east-1).
const pricing = new PricingClient({ region: "us-east-1" });
const priceCache = new Map<string, number | undefined>();
async function linuxOnDemand(region: string, type: string): Promise<number | undefined> {
  const key = `${region}/${type}`;
  if (priceCache.has(key)) return priceCache.get(key);
  const term = (Field: string, Value: string) => ({ Type: "TERM_MATCH" as const, Field, Value });
  const res = await pricing.send(new GetProductsCommand({
    ServiceCode: "AmazonEC2",
    Filters: [
      term("regionCode", region), term("instanceType", type), term("operatingSystem", "Linux"),
      term("tenancy", "Shared"), term("preInstalledSw", "NA"), term("capacitystatus", "Used"),
      term("marketoption", "OnDemand"),
    ],
    MaxResults: 10,
  }));
  let price: number | undefined;
  const list: unknown = res.PriceList;
  for (const item of Array.isArray(list) ? list : []) {
    const product = JSON.parse(String(item)) as {
      terms?: { OnDemand?: Record<string, { priceDimensions?: Record<string, { pricePerUnit?: { USD?: string } }> }> };
    };
    for (const offer of Object.values(product.terms?.OnDemand ?? {})) {
      for (const dim of Object.values(offer.priceDimensions ?? {})) {
        const usd = Number(dim.pricePerUnit?.USD);
        if (usd > 0) price = usd;
      }
    }
  }
  priceCache.set(key, price);
  return price;
}

async function scanRegion(region: string): Promise<Row[]> {
  const ec2 = new EC2Client({ region });
  const types = await instanceTypes(ec2);
  const rows: Row[] = [];
  const pages = paginateDescribeInstances(
    { client: ec2 },
    { Filters: [{ Name: "instance-state-name", Values: ["pending", "running", "stopping", "stopped"] }] },
  );
  for await (const page of pages) {
    for (const res of page.Reservations ?? []) {
      for (const inst of res.Instances ?? []) {
        const type = inst.InstanceType ?? "";
        const info = types.get(type);
        if (!info || info.CurrentGeneration !== false) continue; // current generation, or not offered any more
        const { suggested, note } = suggest(type, types);
        const row: Row = {
          Region: region,
          Instance: inst.InstanceId ?? "",
          Name: inst.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
          State: inst.State?.Name ?? "",
          Type: type,
          Hypervisor: info.Hypervisor ?? "",
          Suggested: suggested.join(" / "),
          Note: note,
        };
        if (withPrices) {
          const now = await linuxOnDemand(region, type);
          const next = await Promise.all(suggested.map((s) => linuxOnDemand(region, s)));
          row.PricePerHour = now === undefined ? "n/a" : `$${now.toFixed(4)}`;
          row.SuggestedPerHour = next.map((p) => (p === undefined ? "n/a" : `$${p.toFixed(4)}`)).join(" / ");
        }
        rows.push(row);
      }
    }
  }
  return rows;
}

function toCsv(rows: Row[]): string {
  const cols: (keyof Row)[] = ["Region", "Instance", "Name", "State", "Type", "Hypervisor", "Suggested", "Note", "PricePerHour", "SuggestedPerHour"];
  const cell = (v: string | undefined) => `"${(v ?? "").replace(/"/g, '""')}"`;
  return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of regions) rows.push(...(await scanRegion(region)));
  console.table(rows.map(({ Note, ...shown }) => shown));
  for (const r of rows.filter((r) => r.Note)) console.log(`${r.Instance} (${r.Type}): ${r.Note}`);
  const byType = new Map<string, number>();
  for (const r of rows) byType.set(r.Type, (byType.get(r.Type) ?? 0) + 1);
  console.log(`${rows.length} instances on previous-generation types in ${regions.join(", ")}`);
  console.log("By type:", Object.fromEntries([...byType].sort((a, b) => b[1] - a[1])));
  if (withPrices) console.log("Prices: On-Demand Linux list price per hour, for comparison only.");
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  console.log("Report only: no instance was stopped or changed.");
}

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

How do you run it?

Terminal

npm install @aws-sdk/client-ec2 @aws-sdk/client-pricing
npm install --save-dev tsx typescript

# Instances on previous-generation types in two Regions
AWS_PROFILE=readonly npx tsx find-previous-generation-ec2.ts --regions us-east-1,eu-west-1

# Add On-Demand Linux prices and export a CSV
AWS_PROFILE=readonly npx tsx find-previous-generation-ec2.ts --regions us-east-1 --prices --csv prev-gen.csv

Sample output

Output

┌─────────┬─────────────┬───────────────────────┬──────────────┬───────────┬──────────────┬────────────┬───────────────────────────┬──────────────┬─────────────────────┐
│ (index) │ Region      │ Instance              │ Name         │ State     │ Type         │ Hypervisor │ Suggested                 │ PricePerHour │ SuggestedPerHour    │
├─────────┼─────────────┼───────────────────────┼──────────────┼───────────┼──────────────┼────────────┼───────────────────────────┼──────────────┼─────────────────────┤
│ 0       │ 'us-east-1' │ 'i-0a1b2c3d4e5f60718' │ 'legacy-api' │ 'running' │ 'm4.large'   │ 'xen'      │ 'm7i.large / m7g.large'   │ '$0.1000'    │ '$0.1008 / $0.0816' │
│ 1       │ 'us-east-1' │ 'i-0b2c3d4e5f6071829' │ 'batch-etl'  │ 'running' │ 'c4.xlarge'  │ 'xen'      │ 'c7i.xlarge / c7g.xlarge' │ '$0.1990'    │ '$0.1785 / $0.1450' │
│ 2       │ 'us-east-1' │ 'i-0c3d4e5f607182930' │ 'cache-old'  │ 'stopped' │ 'r4.large'   │ 'xen'      │ 'r7i.large / r7g.large'   │ '$0.1330'    │ '$0.1323 / $0.1071' │
│ 3       │ 'us-east-1' │ 'i-0d4e5f60718293a41' │ 'ml-train'   │ 'stopped' │ 'p3.2xlarge' │ 'xen'      │ ''                        │ '$3.0600'    │ ''                  │
└─────────┴─────────────┴───────────────────────┴──────────────┴───────────┴──────────────┴────────────┴───────────────────────────┴──────────────┴─────────────────────┘
i-0a1b2c3d4e5f60718 (m4.large): m7g.large is arm64: needs an arm64 AMI; Xen to Nitro: check ENA and NVMe drivers first
i-0b2c3d4e5f6071829 (c4.xlarge): c7g.xlarge is arm64: needs an arm64 AMI; Xen to Nitro: check ENA and NVMe drivers first
i-0c3d4e5f607182930 (r4.large): r7g.large is arm64: needs an arm64 AMI; Xen to Nitro: check ENA and NVMe drivers first
i-0d4e5f60718293a41 (p3.2xlarge): no same-size suggestion: pick a type manually; Xen to Nitro: check ENA and NVMe drivers first
4 instances on previous-generation types in us-east-1
By type: { 'm4.large': 1, 'c4.xlarge': 1, 'r4.large': 1, 'p3.2xlarge': 1 }
Prices: On-Demand Linux list price per hour, for comparison only.
Wrote 4 rows to prev-gen.csv
Report only: no instance was stopped or changed.

IDs are illustrative; prices match the table above. The GPU row gets no suggestion on purpose: moving off P3 is a capacity decision, not a rename.

What should you check before changing the instance type?

Changing the type of an EBS-backed instance means a stop and a start, so plan a window. Instances in an Auto Scaling group change type through the group’s launch template instead; if a group still uses a launch configuration, migrate launch configurations to launch templates first. AWS’s compatibility rules for a type change are the ones that catch previous-generation instances:

  • Architecture. AMIs are architecture-specific, so an x86_64 instance can only be resized to another x86_64 type. The Graviton rows in the table mean launching a new instance from an arm64 AMI and moving the workload, which is where most of the saving is and most of the work.
  • ENA and NVMe drivers. Nitro-based types need the Elastic Network Adapter driver, and they expose EBS volumes as NVMe devices (/dev/nvme0n1 and so on), so /etc/fstab should mount by UUID or label. If the instance doesn’t come back, the steps to troubleshoot why you can’t SSH into an EC2 instance are the place to start.
  • Paravirtual AMIs. An instance launched from a paravirtual (PV) AMI can’t move to an HVM-only type, so check the virtualization type of anything launched from a very old AMI.
  • Reserved Instances and Savings Plans. A Reserved Instance bought for M4 won’t cover M7i. Check what’s committed first with the script to find EC2 Reserved Instances about to expire; an expiring reservation is a natural moment to switch. An EC2 Instance Savings Plan is tied to one instance family too, while a Compute Savings Plan follows you to the new one; the Savings Plans coverage and utilization report shows how much of your commitment is in use before you move.

Also ask whether the instance should exist at all. A stopped r4.large is a candidate for the script to find EC2 instances stopped for weeks, and a busy-looking M4 may be mostly idle, which the example to detect underutilized EC2 instances by CPU checks before you pay for a bigger modern type. If you retire an old instance instead of upgrading it, its volumes’ snapshots stay behind; the script to find orphaned EBS snapshots whose volume is gone catches them afterwards.

Troubleshooting

  • An instance type is missing from the report. The script only knows types that DescribeInstanceTypes returns in that Region. A very old type that AWS no longer offers there is skipped.
  • Prices show n/a. The Price List API had no Shared-tenancy, On-Demand Linux product for that type and Region. Check the type on the EC2 pricing page.
  • AccessDeniedException from the Pricing client. The profile lacks pricing:GetProducts; run without --prices or add the second statement.
  • Slow runs. Pricing lookups are cached per type, but each new type is one API call. The SDK retries throttling automatically; the guide to configure retry and timeout settings in AWS SDK for JavaScript v3 shows how to raise the attempts.

Ask ChatWithCloud instead

For a one-off look, ask ChatWithCloud “Which EC2 instances in us-east-1 run on previous-generation instance types?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result, much like asking it to list AWS resources with natural language. It uses one profile and one Region per session and runs the code it generates without a confirmation step, so connect ChatWithCloud to a read-only AWS profile before you ask. For a multi-Region CSV with prices that you rerun each quarter, the script is the better tool. To see whether EC2 is even the line item worth attacking, find your most expensive AWS service with Cost Explorer first. To hear when EC2 spend climbs again after the move, create an AWS budget alert with AWS SDK v3.

Frequently asked questions

How do I list previous generation instance types with the AWS CLI?

Run aws ec2 describe-instance-types --filters Name=current-generation,Values=false --query "InstanceTypes[].InstanceType" in each Region. That lists the types; you still need describe-instances to see which ones you run.

Will AWS retire previous generation EC2 instances?

AWS’s previous-generation page says it continues to support these types for customers who have optimized their applications around them, and doesn’t give retirement dates. Being on an old type is a reason to plan a move, not an emergency.

Is m5 a previous generation instance type?

No. DescribeInstanceTypes reports M5 as current generation, even though M6 and M7 families exist. Only the families on AWS’s previous-generation list report false.

Can I change the instance type without downtime?

No. An EBS-backed instance must be stopped to change its type, and a move to Graviton means a new instance from an arm64 AMI. Behind a load balancer or Auto Scaling group, you can replace instances one at a time instead.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud