EC2 Dedicated Host utilization is the share of a host’s vCPUs used by the instances on it: TotalVCpus minus AvailableVCpus from DescribeHosts. It matters because a host is billed per hour whether it runs 1 instance or 40. A host with no instances is idle; release it with ReleaseHosts unless a reservation, a license or a Mac minimum keeps it.
Dedicated Hosts usually exist for a reason: per-core or per-socket software licenses, compliance rules or macOS builds. The reason often outlives the workload, and the host keeps billing at a full physical server’s rate. This example is for platform and FinOps engineers who need a clear view of EC2 Dedicated Host utilization across Regions: which hosts are empty, which are mostly empty, and what each costs.
The script reads every allocated host, computes vCPU utilization, prices On-Demand hosts with the AWS Price List API and prints a ranked table. It only releases hosts you name with --release and --apply, and it refuses any host that isn’t empty, is covered by a Host Reservation, belongs to a License Manager host resource group, or is a Mac host inside its first 24 hours.
What does an idle Dedicated Host cost?
The EC2 User Guide says On-Demand Dedicated Hosts are billed per second, with a 60-second minimum, for each active host, regardless of the quantity or size of instances you launch on it. Billing starts at allocation and stops when you release the host. As of September 2026, the AWS Price List for Amazon EC2 (published 25 September 2026) shows these On-Demand rates in US East (N. Virginia); the Amazon EC2 Dedicated Hosts pricing page lists every family and Region.
| Host family | Physical cores | Per hour | 730-hour month |
|---|---|---|---|
mac2 |
8 | $0.65 | $474.50 |
c5 |
36 | $3.366 | $2,457.18 |
m5 |
48 | $5.069 | $3,700.37 |
r5 |
48 | $6.653 | $4,856.69 |
m7i |
96 | $10.644 | $7,770.12 |
Worked example: an m5 host left empty after a SQL Server migration costs $5.069 × 730 = $3,700.37 a month. An r5 host running two r5.2xlarge instances uses 16 of 96 vCPUs, 17% utilization, and costs the full $4,856.69; consolidating those instances onto a host that is already paid for saves the whole second host. Dedicated Host Reservations and Savings Plans lower the hourly rate but don’t change the arithmetic: an empty reserved host is still paid-for capacity doing nothing.
How do you measure EC2 Dedicated Host utilization?
DescribeHosts returns everything the calculation needs, in one paginated call per Region.
| Field | What it tells you |
|---|---|
HostProperties.TotalVCpus / AvailableCapacity.AvailableVCpus |
Capacity and what’s left. Utilization = (total − available) ÷ total. |
Instances |
The instances on the host and their types. An empty list means an idle host. |
AllowsMultipleInstanceTypes |
on when the host accepts several sizes of one family; off when it’s configured for one instance type. |
AutoPlacement |
on means the host accepts untargeted host tenancy launches that match its instance type, so an empty host may be someone’s spare capacity. off (the default) accepts only launches that name its host ID. |
HostReservationId |
Set when a Host Reservation covers the host. AWS says such a host can’t be released until the reservation’s term is over. |
MemberOfServiceLinkedResourceGroup |
true when the host is in a License Manager host resource group, which can allocate and release hosts itself. |
AllocationTime / State |
Age of the host, and whether it’s available, under-assessment, permanent-failure or already released. |
What does the script do?
- Lists hosts per Region
paginateDescribeHostswith 100 hosts per page, skipping hosts that are already released. - Computes utilizationvCPUs used, instance count, and a status:
EMPTY,LOW(under--lowpercent, default 50) orok. - Prices On-Demand hosts
GetProductswithproductFamily = Dedicated Host, the Region code and the instance family, cached per family. Reserved hosts showreserved. - ReportsSorts by utilization, then cost, and totals what the empty hosts cost a month. It also warns when an empty host has auto-placement on.
- Releases only on request
--releasechecks the named hosts against the safety rules;--applycallsReleaseHostsand prints eachSuccessfulandUnsuccessfulitem.
Prerequisites
- Node.js 18 or later with
tsx,@aws-sdk/client-ec2and@aws-sdk/client-pricing. - A read-only profile for the report and a separate one for
--apply; connecting AWS profiles, SSO and roles shows how to keep them apart. - Your license records. The script can’t tell whether a host exists to satisfy a per-socket or per-core license agreement.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadHostsAndPrices",
"Effect": "Allow",
"Action": ["ec2:DescribeHosts", "pricing:GetProducts"],
"Resource": "*"
},
{
"Sid": "ReleaseOnlyWithApply",
"Effect": "Allow",
"Action": "ec2:ReleaseHosts",
"Resource": "arn:aws:ec2:*:123456789012:dedicated-host/*"
}
]
}
Remove the second statement for a report-only role. To check the policy for your own variant of the script, run it through the IAM policy generator for TypeScript AWS SDK code.
The script to report EC2 Dedicated Host utilization
// find-idle-ec2-dedicated-hosts.ts
// Reports EC2 Dedicated Host utilization: vCPUs in use, instance count and the On-Demand hourly rate
// (from the AWS Price List API) for every allocated host, and flags empty and under-used hosts.
// Report only by default. --apply --release h-1,h-2 releases only the named hosts, and only when they
// are empty, On-Demand (no Host Reservation), not in a License Manager host resource group and, for Mac
// hosts, allocated more than 24 hours ago.
// Usage: npx tsx find-idle-ec2-dedicated-hosts.ts [--regions us-east-1,eu-west-1] [--low 50]
// [--release h-0123456789abcdef0 --apply]
import { EC2Client, ReleaseHostsCommand, paginateDescribeHosts, type Host } 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 list = (v: string | undefined): string[] => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const regions = list(flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1");
const lowPercent = Number(flag("--low") ?? 50);
const toRelease = new Set(list(flag("--release")));
const apply = args.includes("--apply");
const HOURS_PER_MONTH = 730;
interface Row {
Region: string;
Host: string;
Name: string;
Family: string;
AZ: string;
Instances: number;
VCpusUsed: string;
Utilization: number;
Status: string;
Billing: string;
PerMonth: number | null;
host: Host;
}
const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// ---- Price List API: On-Demand hourly rate for a Dedicated Host of one instance family ----
const pricing = new PricingClient({ region: "us-east-1" });
const priceCache = new Map<string, number | null>();
async function hostHourlyRate(region: string, family: string): Promise<number | null> {
const key = `${region}|${family}`;
if (priceCache.has(key)) return priceCache.get(key) ?? null;
let rate: number | null = null;
try {
const out = await pricing.send(new GetProductsCommand({
ServiceCode: "AmazonEC2",
Filters: [
{ Type: "TERM_MATCH", Field: "productFamily", Value: "Dedicated Host" },
{ Type: "TERM_MATCH", Field: "regionCode", Value: region },
{ Type: "TERM_MATCH", Field: "instanceType", Value: family }, // the family, for example "m5"
],
MaxResults: 10,
}));
for (const item of out.PriceList ?? []) {
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) rate = usd;
}
}
}
} catch (err) {
console.error(`price lookup for ${family} hosts in ${region}: ${errorText(err)}`);
}
priceCache.set(key, rate);
return rate;
}
const familyOf = (h: Host): string =>
h.HostProperties?.InstanceFamily ?? (h.HostProperties?.InstanceType ?? "").split(".")[0] ?? "";
const isMac = (h: Host): boolean => familyOf(h).startsWith("mac");
const hoursAllocated = (h: Host): number =>
h.AllocationTime ? (Date.now() - h.AllocationTime.getTime()) / 3_600_000 : Number.POSITIVE_INFINITY;
/** Why this host must not be released by the script, or undefined when it's safe. */
function releaseBlocker(h: Host): string | undefined {
if (h.State !== "available") return `state is ${h.State}`;
if ((h.Instances ?? []).length > 0) return `${h.Instances?.length} instances still on it`;
if (h.HostReservationId) return `covered by Host Reservation ${h.HostReservationId}`;
if (h.MemberOfServiceLinkedResourceGroup) return "in a License Manager host resource group";
if (isMac(h) && hoursAllocated(h) < 24) return "Mac hosts have a 24-hour minimum allocation";
return undefined;
}
async function scanRegion(region: string): Promise<Row[]> {
const ec2 = new EC2Client({ region });
const rows: Row[] = [];
for await (const page of paginateDescribeHosts({ client: ec2 }, { MaxResults: 100 })) {
for (const h of page.Hosts ?? []) {
if (h.State === "released" || h.State === "released-permanent-failure") continue;
const total = h.HostProperties?.TotalVCpus ?? 0;
const free = h.AvailableCapacity?.AvailableVCpus ?? total;
const used = total - free;
const utilization = total > 0 ? Math.round((used / total) * 100) : 0;
const instances = (h.Instances ?? []).length;
const family = familyOf(h);
const reserved = Boolean(h.HostReservationId);
const rate = reserved ? null : await hostHourlyRate(region, family);
rows.push({
Region: region,
Host: h.HostId ?? "",
Name: h.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
Family: h.AllowsMultipleInstanceTypes === "on" ? `${family} (mixed)` : h.HostProperties?.InstanceType ?? family,
AZ: h.AvailabilityZone ?? "",
Instances: instances,
VCpusUsed: `${used}/${total}`,
Utilization: utilization,
Status: instances === 0 ? "EMPTY" : utilization < lowPercent ? "LOW" : "ok",
Billing: reserved ? "reservation" : "on-demand",
PerMonth: rate === null ? null : Math.round(rate * HOURS_PER_MONTH * 100) / 100,
host: h,
});
}
}
return rows;
}
async function main(): Promise<void> {
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 allocated Dedicated Hosts in ${regions.join(", ")}.`);
return;
}
rows.sort((a, b) => a.Utilization - b.Utilization || (b.PerMonth ?? 0) - (a.PerMonth ?? 0));
console.table(rows.map(({ host, PerMonth, Utilization, ...shown }) => ({
...shown, "Util %": Utilization, "$/month": PerMonth ?? (shown.Billing === "reservation" ? "reserved" : "unknown"),
})));
const empty = rows.filter((r) => r.Status === "EMPTY");
const emptyCost = empty.reduce((sum, r) => sum + (r.PerMonth ?? 0), 0);
const flags = rows.filter((r) => r.host.AutoPlacement === "on" && r.Status === "EMPTY").length;
const low = rows.filter((r) => r.Status === "LOW").length;
console.log(`${empty.length} empty hosts cost about $${emptyCost.toFixed(2)} a month On-Demand; ` +
`${low} more ${low === 1 ? "is" : "are"} under ${lowPercent}% vCPU utilization.`);
if (flags > 0) console.log(`${flags} empty hosts have auto-placement on: untargeted launches could land there, so check before releasing.`);
for (const id of toRelease) {
const row = rows.find((r) => r.Host === id);
if (!row) {
console.log(`skip ${id}: not found in ${regions.join(", ")}`);
continue;
}
const blocker = releaseBlocker(row.host);
if (blocker) {
console.log(`skip ${id}: ${blocker}`);
continue;
}
if (!apply) {
console.log(`would release ${id} in ${row.Region} (re-run with --apply)`);
continue;
}
try {
const out = await new EC2Client({ region: row.Region }).send(new ReleaseHostsCommand({ HostIds: [id] }));
for (const ok of out.Successful ?? []) console.log(`released ${ok} in ${row.Region}`);
for (const bad of out.Unsuccessful ?? []) {
console.log(`not released ${bad.ResourceId ?? id}: ${bad.Error?.Code ?? ""} ${bad.Error?.Message ?? ""}`.trim());
}
} catch (err) {
console.error(`release ${id}: ${errorText(err)}`);
}
}
}
main().catch((err) => {
console.error(errorText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2 @aws-sdk/client-pricing
npm install --save-dev tsx typescript @types/node
# Utilization report for two Regions
AWS_PROFILE=readonly npx tsx find-idle-ec2-dedicated-hosts.ts --regions us-east-1,eu-west-1
# Flag hosts under 30% vCPU utilization as LOW
AWS_PROFILE=readonly npx tsx find-idle-ec2-dedicated-hosts.ts --low 30
# Preview, then release two empty hosts
AWS_PROFILE=readonly npx tsx find-idle-ec2-dedicated-hosts.ts --release h-0a1b2c3d4e5f60001,h-0a1b2c3d4e5f60003
AWS_PROFILE=ops-admin npx tsx find-idle-ec2-dedicated-hosts.ts --release h-0a1b2c3d4e5f60001 --apply
Sample output
┌─────────┬─────────────┬───────────────────────┬────────────────┬──────────────┬──────────────┬───────────┬───────────┬─────────┬───────────────┬────────┬────────────┐
│ (index) │ Region │ Host │ Name │ Family │ AZ │ Instances │ VCpusUsed │ Status │ Billing │ Util % │ $/month │
├─────────┼─────────────┼───────────────────────┼────────────────┼──────────────┼──────────────┼───────────┼───────────┼─────────┼───────────────┼────────┼────────────┤
│ 0 │ 'us-east-1' │ 'h-0a1b2c3d4e5f60001' │ 'sql-byol-old' │ 'm5.large' │ 'us-east-1a' │ 0 │ '0/96' │ 'EMPTY' │ 'on-demand' │ 0 │ 3700.37 │
│ 1 │ 'us-east-1' │ 'h-0a1b2c3d4e5f60003' │ 'ios-build-2' │ 'mac2.metal' │ 'us-east-1a' │ 0 │ '0/12' │ 'EMPTY' │ 'on-demand' │ 0 │ 474.5 │
│ 2 │ 'us-east-1' │ 'h-0a1b2c3d4e5f60002' │ 'oracle-dev' │ 'r5 (mixed)' │ 'us-east-1b' │ 2 │ '16/96' │ 'LOW' │ 'on-demand' │ 17 │ 4856.69 │
│ 3 │ 'us-east-1' │ 'h-0a1b2c3d4e5f60004' │ 'win-licensed' │ 'c5.large' │ 'us-east-1c' │ 36 │ '72/72' │ 'ok' │ 'reservation' │ 100 │ 'reserved' │
└─────────┴─────────────┴───────────────────────┴────────────────┴──────────────┴──────────────┴───────────┴───────────┴─────────┴───────────────┴────────┴────────────┘
2 empty hosts cost about $4174.87 a month On-Demand; 1 more is under 50% vCPU utilization.
would release h-0a1b2c3d4e5f60001 in us-east-1 (re-run with --apply)
skip h-0a1b2c3d4e5f60003: Mac hosts have a 24-hour minimum allocation
The run used mocked DescribeHosts responses with the real Price List rates, so IDs are illustrative. sql-byol-old is the clear candidate: empty, On-Demand and $3,700.37 a month. ios-build-2 was allocated six hours ago, so the script refuses to release it until the Mac 24-hour minimum has passed. oracle-dev is the expensive one: two instances on a 96-vCPU host. The reserved c5 host is full and shows no price because the reservation, not the On-Demand rate, sets its cost.
Should you release, consolidate or keep a Dedicated Host?
- Release empty On-Demand hosts that no license, audit or build pipeline depends on. The EC2 User Guide says a released host and its ID can’t be reused, and stopped instances that lived on it keep their
hosttenancy setting, so they need a new host before they can start. - Consolidate low-utilization hosts of the same family. Stop the instances, move them to a host with free capacity, and release the host that’s left empty. Instances with host affinity set to
Hostalways restart on the same host, so change their placement before you release it. - Keep hosts that satisfy bring-your-own-license terms. The User Guide notes that, subject to Microsoft licensing terms, you can bring existing Windows Server and SQL Server licenses to Dedicated Hosts with no additional software charge; releasing the host can move you onto license-included pricing, which may cost more than the idle host. Check with whoever owns the license agreement.
- Leave managed hosts alone. Hosts in a License Manager host resource group can be released automatically when the group’s “Release hosts automatically” setting is on; change the group instead.
- Mind commitments. A host covered by a Host Reservation can’t be released before the term ends; the script to find EC2 reservations that are about to expire and the Savings Plans coverage report show what’s committed.
Empty Dedicated Hosts often sit next to instances that were stopped and forgotten, so run the script to find long-stopped EC2 instances at the same time. On shared tenancy, unused On-Demand Capacity Reservations are the equivalent problem: capacity paid for at full rate with nothing running in it, which the script to find unused EC2 Capacity Reservations reports.
Troubleshooting
Client.InvalidHost.OccupiedinUnsuccessful. The API reference example shows this code with the message “Dedicated host ‘h-00548908djdsgfs’ cannot be released as it is occupied”. Stop or terminate the instances first.LimitExceededwhen allocating right after a release. AWS says released hosts can take a few minutes to stop counting toward your limit; wait and retry.$/monthshowsunknown. The Price List query found no Dedicated Host product for that family and Region code. Check the pricing page by hand.- A host you expected is missing.
DescribeHostsonly covers the Region you call, so pass every Region with--regions, and run the script in the account that allocated the hosts. UnauthorizedOperation. EC2’s missing-permission error. Compare with the policy above and troubleshoot the AWS IAM access denied error.
Ask ChatWithCloud instead
To answer “Which Dedicated Hosts in this Region have no instances on them?” without a script, ask ChatWithCloud. 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 on your machine explains the loop. It can be wrong, uses one profile and Region per session and runs changes without a confirmation step, so give it a read-only profile and keep releases in the script. For spend by service, see finding the AWS service you spend most on, and browse more reports in the AWS practical examples hub.
Frequently asked questions
Do you pay for a Dedicated Host with no instances?
Yes. On-Demand hosts are billed per second, with a 60-second minimum, from allocation until release, regardless of how many instances run on them.
How do I check EC2 Dedicated Host utilization?
Call DescribeHosts and compare HostProperties.TotalVCpus with AvailableCapacity.AvailableVCpus. The Instances list shows what is placed on the host.
Can I release a Mac Dedicated Host right away?
No. Mac hosts have a minimum allocation period of 24 hours before they can be released.
What happens to stopped instances when I release their host?
They stay in your account with host tenancy. To start them again, place them on another Dedicated Host.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud