Find the First Free IP Address in a VPC Subnet

Bundle of glowing fiber optic cable ends against a dark background

Photo by JJ Ying on Unsplash

To find an available IP address in an AWS subnet, compute the subnet’s range from its CIDR block, remove the five addresses AWS reserves (the first four and the last), then remove every private IP and delegated prefix that DescribeNetworkInterfaces reports for that subnet. The lowest address left is the first one you can assign.

You need a specific free address when you pin a private IP on an instance, a network load balancer or a database proxy, or when a launch fails with InsufficientFreeAddressesInSubnet and you want to know how full the subnet really is. The console shows a count of available addresses but not which ones they are.

This example gives you a read-only TypeScript script for the AWS SDK for JavaScript v3 that does the CIDR math correctly, scans every network interface in the subnet (or every subnet in a VPC), and prints the first free address with a count you can compare against EC2’s own figure. It belongs with the other AWS SDK v3 examples for networking and inventory.

Which addresses does AWS reserve in every subnet?

AWS reserves five IPv4 addresses in every subnet, whatever its size. For a subnet with the CIDR block 10.0.0.0/24:

Address Reserved for
10.0.0.0 Network address
10.0.0.1 The VPC router
10.0.0.2 The Amazon-provided DNS server (reserved in every subnet)
10.0.0.3 Future use
10.0.0.255 Network broadcast address (AWS doesn’t support broadcast, so it’s reserved)

So a /24 has 256 addresses and 251 you can use, and the smallest subnet AWS allows, a /28, has 16 addresses and 11 usable. The first address you could ever assign in 10.0.0.0/24 is 10.0.0.4.

How does the CIDR math work in TypeScript?

An IPv4 address is a 32-bit number, and a CIDR prefix length says how many leading bits are fixed. RFC 4632, which defines Classless Inter-domain Routing, describes the notation. To get the range, build a mask of prefix leading ones, AND it with the address for the first address, and add 232 − prefix − 1 for the last.

Two JavaScript details trip people up. Bitwise operators work on signed 32-bit integers, so 192 << 24 is negative; the script applies >>> 0 after every bitwise step to get an unsigned value back. And a shift count is taken modulo 32, so 0xffffffff << 32 returns the original value instead of 0. A /0 prefix therefore needs its own case. Neither bug shows up on a typical /24, which is why they survive in copied snippets.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • The @aws-sdk/client-ec2 package.
  • An AWS profile in the subnet’s region with the two describe permissions below.

Which IAM permissions does it need?

Two read-only EC2 actions. Neither supports resource-level permissions, so the resource is *.

free-ip-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadSubnetsAndInterfaces",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeSubnets",
        "ec2:DescribeNetworkInterfaces"
      ],
      "Resource": "*"
    }
  ]
}

The free TypeScript IAM policy generator will produce the same list from the script, and the checklist to review a generated IAM policy for least privilege explains why describe actions end up on *.

The script to find an available IP address in an AWS subnet

first-free-ip.ts

// first-free-ip.ts
// Finds the first IPv4 address in a subnet that isn't reserved by AWS and isn't used by any
// network interface (including IPv4 prefixes delegated to interfaces). Read-only.
// Usage: npx tsx first-free-ip.ts subnet-0abc123 | vpc-0def456
import {
  EC2Client,
  paginateDescribeSubnets,
  paginateDescribeNetworkInterfaces,
  type Subnet,
} from "@aws-sdk/client-ec2";

const target = process.argv[2];
if (!target?.startsWith("subnet-") && !target?.startsWith("vpc-")) {
  throw new Error("Pass a subnet ID (subnet-...) or a VPC ID (vpc-...)");
}
const region = process.env.AWS_REGION ?? "us-east-1";
const ec2 = new EC2Client({ region });

// --- IPv4 CIDR math on unsigned 32-bit integers
function ipToInt(ip: string): number {
  const parts = ip.split(".").map(Number);
  if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
    throw new Error(`Not an IPv4 address: ${ip}`);
  }
  return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
}
const intToIp = (n: number): string => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");

function parseCidr(cidr: string): { first: number; last: number; prefix: number } {
  const [ip, bits] = cidr.split("/");
  const prefix = Number(bits);
  if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) throw new Error(`Bad prefix in ${cidr}`);
  // Shifting by 32 is a no-op in JavaScript, so /0 needs its own case.
  const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
  const first = (ipToInt(ip) & mask) >>> 0;
  const size = 2 ** (32 - prefix);
  return { first, last: first + size - 1, prefix };
}

async function subnetsFor(id: string): Promise<Subnet[]> {
  const input = id.startsWith("vpc-")
    ? { Filters: [{ Name: "vpc-id", Values: [id] }] }
    : { SubnetIds: [id] };
  const subnets: Subnet[] = [];
  for await (const page of paginateDescribeSubnets({ client: ec2 }, input)) subnets.push(...(page.Subnets ?? []));
  // IPv6-only subnets have no IPv4 CidrBlock, so there's nothing to allocate.
  return subnets.filter((s) => s.CidrBlock).sort((a, b) => ipToInt(a.CidrBlock!.split("/")[0]) - ipToInt(b.CidrBlock!.split("/")[0]));
}

async function usedAddresses(subnetId: string): Promise<{ used: Set<number>; enis: number }> {
  const used = new Set<number>();
  let enis = 0;
  const filter = { Filters: [{ Name: "subnet-id", Values: [subnetId] }] };
  for await (const page of paginateDescribeNetworkInterfaces({ client: ec2 }, filter)) {
    for (const eni of page.NetworkInterfaces ?? []) {
      enis++;
      for (const a of eni.PrivateIpAddresses ?? []) if (a.PrivateIpAddress) used.add(ipToInt(a.PrivateIpAddress));
      // Prefix delegation hands a whole /28 to one interface.
      for (const p of eni.Ipv4Prefixes ?? []) {
        if (!p.Ipv4Prefix) continue;
        const { first, last } = parseCidr(p.Ipv4Prefix);
        for (let n = first; n <= last; n++) used.add(n);
      }
    }
  }
  return { used, enis };
}

async function main(): Promise<void> {
  const subnets = await subnetsFor(target);
  if (subnets.length === 0) throw new Error(`No subnets found for ${target} in ${region}`);

  for (const subnet of subnets) {
    const { first, last, prefix } = parseCidr(subnet.CidrBlock!);
    // AWS reserves the first four addresses and the last one in every subnet.
    const reserved = [first, first + 1, first + 2, first + 3, last];
    const { used, enis } = await usedAddresses(subnet.SubnetId!);

    let firstFree: number | undefined;
    let free = 0;
    for (let n = first + 4; n <= last - 1; n++) {
      if (used.has(n)) continue;
      free++;
      if (firstFree === undefined) firstFree = n;
    }

    console.log(`${subnet.SubnetId} ${subnet.CidrBlock} (${subnet.AvailabilityZone})`);
    console.log(`  size /${prefix}: ${last - first + 1} addresses, reserved by AWS: ${reserved.map(intToIp).join(", ")}`);
    console.log(`  in use: ${used.size} address(es) on ${enis} network interface(s)`);
    console.log(`  free by this scan: ${free} | AvailableIpAddressCount from EC2: ${subnet.AvailableIpAddressCount}`);
    console.log(`  first free address: ${firstFree === undefined ? "none, the subnet is full" : intToIp(firstFree)}\n`);
  }
}

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

Network interfaces cover more than EC2 instances. Load balancers, NAT gateways, VPC endpoints, RDS instances and Lambda functions attached to a VPC all create requester-managed interfaces in your subnets, and DescribeNetworkInterfaces returns them too. Interfaces with prefix delegation hold whole /28 blocks, which the script marks as used address by address. Two kinds are worth reviewing when a subnet runs short: interface endpoints nobody calls, which the script to find unused VPC interface endpoints measures, and interfaces left behind by old instances or tooling, which you can find as unattached elastic network interfaces.

How do you run it?

Terminal

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

# One subnet
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx first-free-ip.ts subnet-0abc1234def567890

# Every subnet in a VPC, sorted by address
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx first-free-ip.ts vpc-0123456789abcdef0

Sample output

Output

subnet-0abc1234def567890 10.0.1.0/24 (us-east-1a)
  size /24: 256 addresses, reserved by AWS: 10.0.1.0, 10.0.1.1, 10.0.1.2, 10.0.1.3, 10.0.1.255
  in use: 7 address(es) on 6 network interface(s)
  free by this scan: 244 | AvailableIpAddressCount from EC2: 244
  first free address: 10.0.1.6

subnet-0def5678abc901234 10.0.2.0/28 (us-east-1b)
  size /28: 16 addresses, reserved by AWS: 10.0.2.0, 10.0.2.1, 10.0.2.2, 10.0.2.3, 10.0.2.15
  in use: 11 address(es) on 11 network interface(s)
  free by this scan: 0 | AvailableIpAddressCount from EC2: 0
  first free address: none, the subnet is full

IDs and addresses are illustrative. In the first subnet, 10.0.1.4 and 10.0.1.5 are taken, so 10.0.1.6 is the lowest free address even though higher addresses were assigned first.

Troubleshooting: why don’t the numbers match?

  • The scan finds more free addresses than EC2 reports. A subnet CIDR reservation sets a range aside so AWS won’t assign it to network interfaces automatically. A prefix reservation lowers AvailableIpAddressCount as soon as you create it, but the addresses don’t appear on any interface yet. List reservations with GetSubnetCidrReservations and skip those ranges unless you’re assigning from them on purpose.
  • The address was free a minute ago and now isn’t. Auto Scaling, Lambda and load balancers create interfaces at any time. Treat the result as a snapshot and handle InvalidIPAddress.InUse when you assign the address.
  • A VPC scan skips a subnet. IPv6-only subnets have no IPv4 CIDR block, so there’s nothing to allocate there.
  • UnauthorizedOperation. The profile lacks one of the describe actions. The guide to fix AWS IAM access denied errors shows how to find which policy layer blocks it.

What should you do when a subnet runs out of addresses?

You can’t resize a subnet’s CIDR block. The usual fixes are to create a new, larger subnet, which may need a secondary CIDR block on the VPC, and move workloads there, or to reduce what consumes addresses. VPC-attached Lambda functions and container tasks that each get their own interface are frequent causes; the guide to ask AI about Lambda errors in your AWS account covers finding functions that fail on ENI creation. Network interfaces per region are also a service quota, so it’s worth adding them to how you monitor AWS service quota usage with alerts. If the VPC is peered, pick the new range with the peers in mind, because peering doesn’t work between overlapping CIDR blocks; the script to audit VPC peering connections lists each peer and its routes.

Private addresses are free, but public ones aren’t: unused Elastic IPs are billed, and the example to release unassociated Elastic IP addresses cleans those up. If you’re assigning an address so you can reach an instance, the sibling example to troubleshoot why you can’t SSH into an EC2 instance checks the rest of the path.

Ask ChatWithCloud instead

For a quick answer, ask “How many free IP addresses are left in each subnet of vpc-0123456789abcdef0, and which subnets are over 80% full?” ChatWithCloud writes and runs AWS SDK for JavaScript v2 code on your machine, then answers from the result; the page on how ChatWithCloud runs AWS SDK code locally shows each step. It can get CIDR arithmetic wrong, so use the script above when you need an exact address. The guide to list AWS resources with natural language from your terminal has more inventory questions, and ChatWithCloud’s security model explains why a read-only profile is the right default.

Frequently asked questions

How many usable IP addresses are in an AWS /24 subnet?

251. A /24 has 256 addresses and AWS reserves five of them: the first four and the last.

Can I assign the .1, .2 or .3 address in an AWS subnet?

No. They’re reserved for the VPC router, the Amazon-provided DNS server and future use. The first assignable address is the network address plus 4.

Does AvailableIpAddressCount include reserved addresses?

No. It’s the number of addresses still free for assignment, after the five reserved addresses and those in use are removed.

Which private IP ranges can a VPC use?

A VPC’s IPv4 blocks are usually from the RFC 1918 private ranges: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. Each VPC CIDR block must be between /16 and /28.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud