Photo by Anne Nygård on Unsplash
To get EC2 Spot price history, call DescribeSpotPriceHistory with the instance types, a product description such as Linux/UNIX and a StartTime up to 90 days back, and page through the results. Group the prices by Availability Zone, weight each price by how long it lasted, and compare the cheapest zone with the On-Demand rate from the AWS Price List API.
Spot Instances use spare EC2 capacity at a discount, and the discount isn’t the same everywhere. The same instance type can cost noticeably more in one Availability Zone than in the zone next to it, and the gap moves over weeks. If you run batch jobs, CI runners or stateless workers on Spot, the EC2 Spot price history tells you where to point them and what you’re saving.
This example is for engineers and FinOps-minded leads who want numbers instead of the console graph. You get a read-only TypeScript script for the AWS SDK for JavaScript v3 that summarizes each zone and adds the On-Demand price for comparison. It belongs with the other AWS SDK v3 cost and cleanup examples.
How are EC2 Spot prices set?
The Amazon EC2 documentation says Spot prices are set by EC2 and adjust gradually based on long-term trends in supply and demand for Spot capacity. You pay the current Spot price when your request is fulfilled, never more than the On-Demand price, and the console and API show the last 90 days.
Three details shape how you read the data:
- The API returns price changes, not a price per hour. Each record is the price from its
Timestampuntil the next change. A simple average of records overweights zones whose price moves a lot, so the script weights each price by how long it held. - It includes one record from before your window. With a start and end time,
DescribeSpotPriceHistoryalso returns the last change beforeStartTime, which is the price in effect when the window opens. The script clips it to the window. - Zone names are per account.
us-east-1ain your account may be a different physical zone thanus-east-1ain another. Each record also carries anAvailabilityZoneIdsuch asuse1-az4, which is the same in every account.
How much cheaper is Spot than On-Demand?
It depends on the type, zone and month, which is why the script compares against live On-Demand prices. For reference, the On-Demand Linux prices in us-east-1 as of September 2026, from AWS’s published price list:
| Instance type | On-Demand per hour | Per month (730 h) |
|---|---|---|
m7i.large |
$0.1008 | $73.58 |
m7g.large |
$0.0816 | $59.57 |
c7i.xlarge |
$0.1785 | $130.31 |
r6i.large |
$0.1260 | $91.98 |
Worked example: if the history shows a 30-day time-weighted Spot average of $0.0400 an hour for m7i.large in your cheapest zone, 10 workers cost 10 × $0.0400 × 730 = $292.00 a month on Spot against 10 × $0.1008 × 730 = $735.84 On-Demand, a 60% saving. Run the script to replace $0.0400 with your real number. The FinOps Foundation lists Spot next to Savings Plans and Reserved Instances in its rate optimization capability; the scripts to check Savings Plans coverage and utilization and find EC2 Reserved Instances about to expire cover the commitment side.
What does the script do?
- Reads the Spot price history
paginateDescribeSpotPriceHistorywith your instance types, one product description, and a window of up to 90 days. - Summarizes each zoneTime-weighted average, minimum, maximum, latest price and the number of price changes, per instance type and Availability Zone.
- Picks the cheapest zoneSorted by average, not latest price, so one quiet day doesn’t decide.
- Adds the On-Demand price
GetProductsfrom the AWS Price List Query API with Linux, shared tenancy and no pre-installed software filters, then the saving and a monthly figure at 730 hours. - Changes nothingIt makes no Spot request and launches nothing.
--no-on-demandskips the Price List call.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-ec2and@aws-sdk/client-pricing. - A read-only profile; the guide to AWS SDK v3 credential providers such as fromIni and fromSSO shows how the SDK finds it.
- The instance types you’d actually run. Compare types you can use interchangeably, not the whole catalog.
Which IAM permissions does it need?
Two read actions, neither of which supports resource-level permissions. ReadOnlyAccess includes the EC2 one; check that yours also allows pricing:GetProducts.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadSpotAndOnDemandPrices",
"Effect": "Allow",
"Action": [
"ec2:DescribeSpotPriceHistory",
"pricing:GetProducts"
],
"Resource": "*"
}
]
}
If you extend the script, the IAM policy generator for TypeScript AWS SDK code keeps the policy in step with the calls it makes.
The full script to compare EC2 Spot price history
// spot-price-history.ts
// Pulls EC2 Spot price history (up to the last 90 days) for a few instance types in one Region,
// computes a time-weighted average, min, max and latest price per Availability Zone, picks the
// cheapest zone per type and compares it with the On-Demand price from the AWS Price List API.
// Read-only: it never requests or launches Spot capacity.
// Usage: npx tsx spot-price-history.ts [--region us-east-1] [--types m7i.large,c7i.xlarge] [--days 30] [--os "Linux/UNIX"] [--no-on-demand]
import { EC2Client, paginateDescribeSpotPriceHistory, type _InstanceType } from "@aws-sdk/client-ec2";
import { GetProductsCommand, PricingClient } 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 region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const types = (flag("--types") ?? "m7i.large,c7i.xlarge").split(",").map((t) => t.trim()).filter(Boolean);
const days = Math.min(Number(flag("--days") ?? 30), 90); // the API only goes back 90 days
const os = flag("--os") ?? "Linux/UNIX";
const withOnDemand = !args.includes("--no-on-demand");
interface Point {
at: number; // epoch ms
price: number;
}
interface ZoneStats {
Type: string;
Zone: string;
Avg: number;
Min: number;
Max: number;
Latest: number;
Changes: number;
}
// Time-weighted average: each price holds until the next change (or until "now").
function stats(type: string, zone: string, points: Point[], from: number, to: number): ZoneStats {
const sorted = [...points].sort((a, b) => a.at - b.at);
let weighted = 0;
let covered = 0;
sorted.forEach((p, i) => {
const start = Math.max(p.at, from); // the API also returns the last change before StartTime
const end = i + 1 < sorted.length ? (sorted[i + 1]?.at ?? to) : to;
if (end > start) {
weighted += p.price * (end - start);
covered += end - start;
}
});
const prices = sorted.map((p) => p.price);
return {
Type: type,
Zone: zone,
Avg: covered ? weighted / covered : Number.NaN,
Min: Math.min(...prices),
Max: Math.max(...prices),
Latest: sorted[sorted.length - 1]?.price ?? Number.NaN,
Changes: sorted.length,
};
}
// On-Demand hourly price for shared-tenancy Linux with no pre-installed software, from the Price List Query API.
async function onDemandPrice(pricing: PricingClient, instanceType: string): Promise<number | undefined> {
const match = (Field: string, Value: string) => ({ Type: "TERM_MATCH" as const, Field, Value });
const res = await pricing.send(
new GetProductsCommand({
ServiceCode: "AmazonEC2",
Filters: [
match("instanceType", instanceType),
match("regionCode", region),
match("operatingSystem", "Linux"),
match("tenancy", "Shared"),
match("preInstalledSw", "NA"),
match("capacitystatus", "Used"),
match("licenseModel", "No License required"),
],
MaxResults: 10,
}),
);
for (const item of res.PriceList ?? []) {
const product = JSON.parse(item) as {
terms?: { OnDemand?: Record<string, { priceDimensions?: Record<string, { pricePerUnit?: { USD?: string } }> }> };
};
for (const term of Object.values(product.terms?.OnDemand ?? {})) {
for (const dim of Object.values(term.priceDimensions ?? {})) {
const usd = Number(dim.pricePerUnit?.USD);
if (usd > 0) return usd;
}
}
}
return undefined;
}
async function main(): Promise<void> {
if (os !== "Linux/UNIX" && withOnDemand) console.log("Note: the On-Demand column is for Linux; pass --no-on-demand for other platforms.");
const ec2 = new EC2Client({ region });
const to = Date.now();
const from = to - days * 24 * 3600 * 1000;
// type -> zone -> price points
const series = new Map<string, Map<string, Point[]>>();
const pages = paginateDescribeSpotPriceHistory(
{ client: ec2 },
{ InstanceTypes: types as _InstanceType[], ProductDescriptions: [os], StartTime: new Date(from), EndTime: new Date(to) },
);
for await (const page of pages) {
for (const p of page.SpotPriceHistory ?? []) {
if (!p.InstanceType || !p.AvailabilityZone || !p.SpotPrice || !p.Timestamp) continue;
const zones = series.get(p.InstanceType) ?? new Map<string, Point[]>();
zones.set(p.AvailabilityZone, [...(zones.get(p.AvailabilityZone) ?? []), { at: p.Timestamp.getTime(), price: Number(p.SpotPrice) }]);
series.set(p.InstanceType, zones);
}
}
const pricing = new PricingClient({ region: "us-east-1" }); // a Price List Query API endpoint, not the Region you price
const rows: ZoneStats[] = [];
const summary: Record<string, string | number>[] = [];
for (const type of types) {
const zones = series.get(type);
if (!zones?.size) {
console.log(`${type}: no Spot price history for ${os} in ${region} (not offered, or no data in the window)`);
continue;
}
const perZone = [...zones].map(([zone, points]) => stats(type, zone, points, from, to)).sort((a, b) => a.Avg - b.Avg);
rows.push(...perZone);
const best = perZone[0];
const worst = perZone[perZone.length - 1];
if (!best || !worst) continue;
const od = withOnDemand ? await onDemandPrice(pricing, type) : undefined;
summary.push({
Type: type,
CheapestZone: best.Zone,
SpotAvg: best.Avg.toFixed(4),
PriciestZoneAvg: worst.Avg.toFixed(4),
OnDemand: od?.toFixed(4) ?? "n/a",
SavingVsOnDemand: od ? `${Math.round((1 - best.Avg / od) * 100)}%` : "n/a",
MonthlyAtAvg: (best.Avg * 730).toFixed(2), // 730 hours ≈ 1 month
});
}
console.table(rows.map((r) => ({ ...r, Avg: r.Avg.toFixed(4), Min: r.Min.toFixed(4), Max: r.Max.toFixed(4), Latest: r.Latest.toFixed(4) })));
console.table(summary);
console.log(`${os}, ${region}, last ${days} days, USD per instance-hour. Read-only: no Spot request was made.`);
}
main().catch((err) => {
console.error(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
# Two interchangeable types, last 30 days, us-east-1
AWS_PROFILE=readonly npx tsx spot-price-history.ts --region us-east-1 --types m7i.large,m6i.large --days 30
# The full 90 days, Spot only
AWS_PROFILE=readonly npx tsx spot-price-history.ts --region eu-west-1 --types c7i.xlarge --days 90 --no-on-demand
Sample output
┌─────────┬─────────────┬──────────────┬──────────┬─────────────────┬──────────┬──────────────────┬──────────────┐
│ (index) │ Type │ CheapestZone │ SpotAvg │ PriciestZoneAvg │ OnDemand │ SavingVsOnDemand │ MonthlyAtAvg │
├─────────┼─────────────┼──────────────┼──────────┼─────────────────┼──────────┼──────────────────┼──────────────┤
│ 0 │ 'm7i.large' │ 'us-east-1d' │ '0.0391' │ '0.0512' │ '0.1008' │ '61%' │ '28.54' │
│ 1 │ 'm6i.large' │ 'us-east-1b' │ '0.0374' │ '0.0487' │ '0.0960' │ '61%' │ '27.30' │
└─────────┴─────────────┴──────────────┴──────────┴─────────────────┴──────────┴──────────────────┴──────────────┘
Linux/UNIX, us-east-1, last 30 days, USD per instance-hour. Read-only: no Spot request was made.
The Spot figures here are illustrative; the On-Demand column matches the September 2026 price list. The per-zone table above the summary (not shown) is where the real decision sits: a zone with a low average but many price changes has been less predictable than one with a flat, slightly higher price.
Is the cheapest zone the best place to run Spot?
Not on its own. Price history says nothing about interruptions. When EC2 needs the capacity back, it gives a Spot Instance a two-minute interruption notice, delivered as an EventBridge event and in instance metadata, then stops, hibernates or terminates it. Put everything in the cheapest zone and one capacity crunch takes out the whole fleet.
- Spread across zones and types. Use the history to drop zones that are consistently expensive, not to pick exactly one.
- Check the Spot placement score.
GetSpotPlacementScoresrates how likely a request is to succeed, from 1 to 10, per Region or per Availability Zone withSingleAvailabilityZone. It needsec2:GetSpotPlacementScores, has no charge, and returns low scores if you list fewer than three instance types. - Handle the notice. Checkpoint work or drain from a queue so an interruption only costs a retry. Batch jobs run as a state machine, started as shown in the guide to start a Step Functions execution with AWS SDK v3, can retry an interrupted step on their own.
For always-on servers, Spot is usually the wrong tool; first detect and stop underutilized EC2 instances by CPU and find previous-generation EC2 instances to upgrade.
Troubleshooting
- “no Spot price history” for a type. The type isn’t offered in that Region for that product description, or the name is wrong. Product descriptions must match the API’s values, such as
Linux/UNIXorWindows. - On-Demand shows
n/a. The Price List filters matched nothing, usually for a very new type or a non-Linux--os. The Price List Query API is served from specific endpoints, so the script always calls it in us-east-1 whatever Region you price. AccessDeniedExceptionfrom Pricing. The profile lackspricing:GetProducts, or a service control policy blocks it; the guide to troubleshoot AWS IAM access denied errors shows how to tell which.--days 120returns 90 days. The script caps the window at the 90 days the API supports.
Ask ChatWithCloud instead
For a one-off question, ask ChatWithCloud “What was the average Spot price for m7i.large in each us-east-1 zone over the last week?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and explains the numbers, as the page on how ChatWithCloud turns questions into AWS SDK calls describes. It works with one profile and Region per session and can be wrong, so check figures you’ll base a purchase on. For the bill itself, see how to ask AI why your AWS bill increased.
Frequently asked questions
How far back does EC2 Spot price history go?
90 days. StartTime can be up to 90 days in the past; older history isn’t available from the API or the console.
How do I get Spot price history with the AWS CLI?
Run aws ec2 describe-spot-price-history --instance-types m7i.large --product-descriptions "Linux/UNIX" --start-time 2026-09-01T00:00:00Z. The CLI paginates for you and returns one record per price change.
Why is Spot cheaper in one Availability Zone?
Each instance type in each zone is a separate capacity pool, and prices follow long-term supply and demand in that pool. A zone with more spare capacity for a type tends to be cheaper.
Can the Spot price go above On-Demand?
No. AWS’s documentation says Spot Instances launch at the current Spot price, not exceeding the On-Demand price.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud