Find and Release Unassociated Elastic IP Addresses

Blue network cables plugged into a row of ports on a network switch

Photo by Jonathan on Unsplash

To release unassociated Elastic IP addresses, call DescribeAddresses in each region, keep only addresses with no AssociationId, instance or network interface, then call ReleaseAddress with each AllocationId. Idle addresses cost the same hourly rate as attached ones, so every forgotten Elastic IP is a small, steady charge. Report first, release second.

This example is for engineers cleaning up an account after load tests, migrations or deleted instances left Elastic IPs behind. You get a TypeScript script for the AWS SDK for JavaScript v3 that lists every unassociated Elastic IP in one region or all of them, estimates what they cost, and only releases them when you add --release. Addresses that are attached, managed by another AWS service or tagged keep=true are never touched.

It’s part of our collection of runnable AWS SDK v3 examples, alongside the companion script to find and tag unattached EBS volumes, another common source of idle spend.

What does an unassociated Elastic IP cost?

AWS has charged for every public IPv4 address since February 2024, whether it’s attached to a running resource or sitting idle in your account. The rate is the same in both cases. To count the addresses in use as well, the script to find EC2 instances with public IP addresses estimates their monthly charge.

Public IPv4 address state Price per hour Per 30-day month
In use (attached to a resource) $0.005 $3.60
Idle (allocated, not associated) $0.005 $3.60

Prices as of September 2026, from the Amazon VPC pricing page (public IPv4 address section). The monthly figure is 30 days × 24 hours × $0.005. Ten forgotten addresses come to $36.00 per 30 days, and they rarely show up as a line item anyone owns. If a jump in the VPC or EC2 line is what sent you here, confirm the cause in Cost Explorer before you clean up.

What does this script do?

  1. Pick the regionsBy default it scans the region in AWS_REGION. With --all-regions it calls DescribeRegions, which returns the regions enabled for your account.
  2. List every Elastic IPDescribeAddresses returns all addresses in a region in one response, so there’s no paginator to loop over.
  3. Apply the safety checksAddresses with an AssociationId, InstanceId or NetworkInterfaceId are in use and dropped. Addresses with a ServiceManaged value (a load balancer, RDS or a regional NAT gateway manages them), customer-owned or carrier IPs, and anything tagged keep=true are listed as skipped.
  4. Report or releaseWithout flags it prints what it would release and the estimated monthly saving. With --release it calls ReleaseAddress with the AllocationId and the address’s NetworkBorderGroup.

Prerequisites

  • Node.js 18 or later with npm, plus tsx to run TypeScript directly.
  • The @aws-sdk/client-ec2 package.
  • An AWS profile. For the report, read-only access is enough; releasing needs the extra statement below.

Which IAM permissions does it need?

ec2:DescribeAddresses and ec2:DescribeRegions don’t support resource-level permissions, so they use "*". ec2:ReleaseAddress can be scoped to Elastic IP ARNs. Leave the second statement out of the policy for report-only runs.

release-eips-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportElasticIps",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeAddresses",
        "ec2:DescribeRegions"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ReleaseElasticIps",
      "Effect": "Allow",
      "Action": "ec2:ReleaseAddress",
      "Resource": "arn:aws:ec2:*:*:elastic-ip/*"
    }
  ]
}

If you edit the script, run it through the IAM policy generator for TypeScript SDK code to draft a matching policy, then check the result against the checklist to review a generated IAM policy for least privilege.

The full script to release unassociated Elastic IP addresses

release-unassociated-eips.ts

// release-unassociated-eips.ts
// Lists Elastic IP addresses that aren't associated with anything and, only when
// you pass --release, releases them. The default run is a report and changes nothing.
import {
  EC2Client,
  DescribeAddressesCommand,
  DescribeRegionsCommand,
  ReleaseAddressCommand,
  type Address,
} from "@aws-sdk/client-ec2";

// Public IPv4 price from the Amazon VPC pricing page, as of September 2026.
const PRICE_PER_IP_HOUR = 0.005;
const HOURS_PER_MONTH = 720; // 30 days, as in the AWS pricing examples

const args = process.argv.slice(2);
const RELEASE = args.includes("--release");
const ALL_REGIONS = args.includes("--all-regions");
const KEEP_TAG = "keep"; // tag an address keep=true to protect it
const HOME_REGION = process.env.AWS_REGION ?? "us-east-1";

type Row = { region: string; publicIp: string; allocationId: string; name: string; action: string };

function tag(address: Address, key: string): string | undefined {
  return address.Tags?.find((t) => t.Key?.toLowerCase() === key)?.Value;
}

// Why an address must not be released, or undefined when it is safe to release.
function skipReason(a: Address): string | undefined {
  if (a.AssociationId || a.InstanceId || a.NetworkInterfaceId) return "associated";
  if (a.ServiceManaged) return `managed by ${a.ServiceManaged}`;
  if (a.CustomerOwnedIp || a.CarrierIp) return "customer-owned or carrier IP";
  if (!a.AllocationId) return "no allocation ID";
  if (tag(a, KEEP_TAG)?.toLowerCase() === "true") return "tagged keep=true";
  return undefined;
}

async function regionsToScan(): Promise<string[]> {
  if (!ALL_REGIONS) return [HOME_REGION];
  const ec2 = new EC2Client({ region: HOME_REGION });
  const res = await ec2.send(new DescribeRegionsCommand({})); // enabled regions only
  return (res.Regions ?? []).map((r) => r.RegionName).filter((r): r is string => !!r);
}

async function scanRegion(region: string): Promise<Row[]> {
  const ec2 = new EC2Client({ region });
  // DescribeAddresses returns every Elastic IP in the region in one response.
  const { Addresses = [] } = await ec2.send(new DescribeAddressesCommand({}));
  const rows: Row[] = [];

  for (const a of Addresses) {
    const reason = skipReason(a);
    if (reason === "associated") continue; // in use: never touched, not listed
    const row: Row = {
      region,
      publicIp: a.PublicIp ?? "-",
      allocationId: a.AllocationId ?? "-",
      name: tag(a, "name") ?? "",
      action: reason ? `skipped (${reason})` : RELEASE ? "released" : "would release",
    };
    if (!reason && RELEASE) {
      try {
        await ec2.send(
          new ReleaseAddressCommand({
            AllocationId: a.AllocationId,
            NetworkBorderGroup: a.NetworkBorderGroup,
          }),
        );
      } catch (err) {
        row.action = `failed: ${(err as Error).name}`;
      }
    }
    rows.push(row);
  }
  return rows;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of await regionsToScan()) rows.push(...(await scanRegion(region)));

  if (rows.length === 0) {
    console.log("No unassociated Elastic IP addresses found.");
    return;
  }
  console.table(rows);

  const idle = rows.filter((r) => !r.action.startsWith("skipped")).length;
  const monthly = idle * PRICE_PER_IP_HOUR * HOURS_PER_MONTH;
  console.log(`${idle} releasable address(es), about $${monthly.toFixed(2)} per 30 days at $${PRICE_PER_IP_HOUR}/hour.`);
  if (!RELEASE && idle > 0) console.log("Dry run: nothing was released. Re-run with --release to release them.");
}

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

The price constant is dated in a comment on purpose. If AWS changes the rate, update PRICE_PER_IP_HOUR from the pricing page rather than trusting an old copy of this script.

How do you run it?

Terminal

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

# Report only, current region (the default: nothing is released)
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx release-unassociated-eips.ts

# Report across every enabled region
AWS_PROFILE=readonly npx tsx release-unassociated-eips.ts --all-regions

# Release after you have reviewed the report
AWS_PROFILE=admin npx tsx release-unassociated-eips.ts --all-regions --release

Before you pass –release: a released address goes back to the AWS pool. AWS lets you try to recover a specific released address with AllocateAddress, but only if no other account has taken it. Check DNS records, firewall allowlists and partner integrations that reference the IP first, and tag anything you must keep with keep=true.

Sample output

Output (dry run)

┌─────────┬─────────────┬───────────────┬──────────────────────────────┬───────────────┬──────────────────────────────┐
│ (index) │ region      │ publicIp      │ allocationId                 │ name          │ action                       │
├─────────┼─────────────┼───────────────┼──────────────────────────────┼───────────────┼──────────────────────────────┤
│ 0       │ 'us-east-1' │ '3.214.10.21' │ 'eipalloc-0a1b2c3d4e5f6a7b8' │ 'old-bastion' │ 'would release'              │
│ 1       │ 'us-east-1' │ '52.4.188.90' │ 'eipalloc-0f9e8d7c6b5a49382' │ 'partner-vpn' │ 'skipped (tagged keep=true)' │
│ 2       │ 'eu-west-1' │ '34.240.77.3' │ 'eipalloc-01234abcd5678ef90' │ ''            │ 'would release'              │
└─────────┴─────────────┴───────────────┴──────────────────────────────┴───────────────┴──────────────────────────────┘
2 releasable address(es), about $7.20 per 30 days at $0.005/hour.
Dry run: nothing was released. Re-run with --release to release them.

Addresses and IDs are illustrative. Attached addresses never appear in the table because the script drops them before building a row.

Troubleshooting: why won’t an Elastic IP release?

  • UnauthorizedOperation on ReleaseAddress. The profile can describe but not release. Add the second policy statement, or follow the steps to troubleshoot AWS IAM access denied errors if an SCP or permissions boundary is in the way.
  • InvalidIPAddress.InUse. Someone associated the address between the report and the release. Re-run the report; the script will now drop it as in use.
  • AuthFailure. The address was already released, for example by a teammate or an earlier run, and another AWS account now holds it. There’s nothing left to clean up.
  • InvalidAddress.NotFound. The network border group didn’t match, which matters for addresses in Local Zones or Wavelength Zones. The script passes each address’s own NetworkBorderGroup; keep that line if you trim the code.
  • An address shows as skipped (managed by alb, nlb, rds or rnat). The owning service allocated it. Delete or reconfigure that resource instead; the address is released with it.
  • You hit the Elastic IP limit while addresses sit idle. The default quota is 5 per region. Releasing idle ones frees headroom under that limit.

Should you release Elastic IPs on stopped instances too?

This script leaves them alone, because an Elastic IP associated with a stopped instance still has an AssociationId. It’s billed at the same public IPv4 rate, though. If the instance is stopped for good, decide about the instance first: the example to detect and stop underutilized EC2 instances by CPU and the EC2 instance report by type, launch time and region help you see what’s still needed, and the script to find EC2 instances stopped for weeks and still costing you totals the storage and Elastic IP charges each one keeps paying.

Ask ChatWithCloud instead

For a quick look without installing anything, run ChatWithCloud and ask “Which Elastic IP addresses aren’t associated with anything, and what do they cost per month?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile, and summarizes the result; how ChatWithCloud runs AWS SDK code locally explains the loop. Changes run without a confirmation step, so ask for the list with a read-only profile and release addresses with the script above. The ChatWithCloud security model covers what leaves your machine, and each session uses one profile and one region.

Frequently asked questions

Are you charged for an Elastic IP that isn’t attached?

Yes. An idle public IPv4 address costs $0.005 per hour as of September 2026, the same as one attached to a running resource. That’s about $3.60 per 30-day month per address.

Can I get a released Elastic IP address back?

Sometimes. You can ask for the same address with AllocateAddress, but only if no other AWS account has been allocated it since. Treat a release as permanent.

How do I find unused Elastic IPs in all regions?

Run the script with --all-regions. It calls DescribeRegions for the enabled regions and DescribeAddresses in each one, then lists the addresses with no association.

Does releasing an Elastic IP affect a running instance?

Not with this script, because it never releases an associated address. If you release one by hand while it’s attached, the resource loses that public IP and anything pointing at it stops reaching it.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud