Photo by Amira El Fohail on Unsplash
To find unused ECR repositories, list every repository with DescribeRepositories, then call DescribeImages on each and take the newest imagePushedAt and lastRecordedPullTime. A repository with no push and no pull in 90 days or more is a candidate for deletion, and its summed imageSizeInBytes estimates what it costs you each month.
Container registries collect repositories the way file shares collect folders: one per experiment, per branch pipeline, per service that was merged into another. Each keeps every image it was ever given unless something removes them, and ECR charges for that storage every month.
This example is for engineers who want to find unused ECR repositories and remove them, not trim images inside the ones still in use. For that second job, the script to set an ECR lifecycle policy that deletes old images is the better fit. This one reports each repository’s image count, stored size, last push, last pull and monthly storage cost, and with --apply deletes the repositories that crossed your threshold.
What does an unused ECR repository cost?
An empty repository costs nothing. You pay for the images in it, per GB-month, whether anyone pulls them or not.
| Private registry item (US East, N. Virginia) | Price as of September 2026 |
|---|---|
| Storage | $0.10 per GB-month |
| Data transfer to services in the same Region | $0.00 |
| Free tier (new customers, first year) | 500 MB per month of private storage |
Prices are from the AWS Price List API file for Amazon ECR (published 11 September 2026) and the Amazon ECR pricing page. Check your Region there before you rely on them.
Worked example
Take the sample output below. ml/feature-builder holds 2 images totaling 12.4 GB: 12.4 × $0.10 = $1.24 per month. hackathon-2025 adds 1.2 × $0.10 = $0.12, and the empty repository adds nothing, so deleting all three saves about $1.36 per month, or $16.32 a year. Small per repository, but machine-learning and monorepo registries can hold hundreds of GB in repositories nobody has pulled from in a year, and at 500 GB the same arithmetic is $50 a month.
The script’s figure is an estimate. imageSizeInBytes is the compressed size ECR reports for each image, and for a multi-architecture manifest list it’s the size of the largest image in the list, not the sum. Compare the total with the ECR storage line in your bill; the script to get AWS billing details broken down by service pulls that line from Cost Explorer.
How do you find unused ECR repositories?
Two timestamps per image tell you whether a repository is alive:
imagePushedAt: when the image was pushed. The newest one tells you when a pipeline last built into the repository.lastRecordedPullTime: when ECR last recorded a pull. ECR refreshes it at least once every 24 hours, so it’s accurate to about a day, which is plenty for a 90-day threshold. It’s absent for images that were never pulled.
The script takes the latest of the newest push, the newest pull and the repository’s createdAt, and flags the repository if that date is older than --older-than days. Using createdAt keeps a repository created yesterday and not yet pushed to from being flagged.
What does the script do?
- Lists repositories
paginateDescribeRepositoriesin the chosen Region. - Reads every image
paginateDescribeImagesper repository, counting images and summingimageSizeInBytes, and keeping the newest push and pull. - Prices and ranksConverts bytes to GB, multiplies by $0.10 per GB-month and sorts unused repositories first, largest first.
- Deletes, if asked
--applyneeds an explicit--older-than, then callsDeleteRepositorywithforce: truefor each flagged repository (or only those in--names). Force deletes the images too.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-ecr. - A read-only profile for the report and a separate one for
--apply; see the guide to AWS SDK v3 credential providers and profiles. - For deletion: a check that no ECS task definition, EKS manifest or Lambda function references the repository URI.
Which IAM permissions does it need?
All three actions support repository ARNs. Only the last statement changes anything.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadRepositoriesAndImages",
"Effect": "Allow",
"Action": ["ecr:DescribeRepositories", "ecr:DescribeImages"],
"Resource": "arn:aws:ecr:us-east-1:111122223333:repository/*"
},
{
"Sid": "DeleteOnlyWithApply",
"Effect": "Allow",
"Action": "ecr:DeleteRepository",
"Resource": "arn:aws:ecr:us-east-1:111122223333:repository/*"
}
]
}
Narrow the delete statement to a prefix such as repository/sandbox/* if only some repositories are fair game. The IAM policy generator for TypeScript code rebuilds the action list if you extend the script.
The script to find unused ECR repositories
// find-unused-ecr-repositories.ts
// Lists every private ECR repository in a Region with its image count, stored size, last push and last
// recorded pull, and flags repositories with no push and no pull in --older-than days (default 90).
// --apply deletes the flagged repositories and all their images (force), and requires --older-than.
// Usage:
// npx tsx find-unused-ecr-repositories.ts [--region us-east-1] [--older-than 90]
// npx tsx find-unused-ecr-repositories.ts --region us-east-1 --older-than 180 --apply [--names repo-a,repo-b]
import {
ECRClient,
DeleteRepositoryCommand,
paginateDescribeImages,
paginateDescribeRepositories,
type Repository,
} from "@aws-sdk/client-ecr";
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 olderThanFlag = flag("--older-than");
const days = Number(olderThanFlag ?? 90);
const apply = args.includes("--apply");
const only = new Set((flag("--names") ?? "").split(",").map((n) => n.trim()).filter(Boolean));
const PRICE_PER_GB_MONTH = 0.1; // ECR storage, us-east-1, AWS Price List API, September 2026
const DAY = 86_400_000;
const ecr = new ECRClient({ region });
interface Row {
Repository: string;
Images: number;
SizeGB: number;
LastPush: string;
LastPull: string;
UsdPerMonth: number;
Unused: boolean;
}
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const day = (d?: Date): string => (d ? d.toISOString().slice(0, 10) : "never");
const latest = (a: Date | undefined, b: Date | undefined): Date | undefined => (!a ? b : !b ? a : a > b ? a : b);
async function inspect(repo: Repository, cutoff: Date): Promise<Row> {
const name = repo.repositoryName ?? "";
let images = 0;
let bytes = 0;
let lastPush: Date | undefined;
let lastPull: Date | undefined;
for await (const page of paginateDescribeImages({ client: ecr }, { repositoryName: name })) {
for (const img of page.imageDetails ?? []) {
images++;
bytes += img.imageSizeInBytes ?? 0;
lastPush = latest(lastPush, img.imagePushedAt);
lastPull = latest(lastPull, img.lastRecordedPullTime);
}
}
const lastActivity = latest(latest(lastPush, lastPull), repo.createdAt);
const sizeGB = bytes / 1024 ** 3;
return {
Repository: name,
Images: images,
SizeGB: Number(sizeGB.toFixed(2)),
LastPush: day(lastPush),
LastPull: day(lastPull),
UsdPerMonth: Number((sizeGB * PRICE_PER_GB_MONTH).toFixed(2)),
Unused: !lastActivity || lastActivity < cutoff,
};
}
async function main(): Promise<void> {
if (!Number.isFinite(days) || days < 1) throw new Error("--older-than must be a number of days, 1 or more");
if (apply && !olderThanFlag) throw new Error("--apply requires an explicit --older-than, so the threshold is a deliberate choice");
const cutoff = new Date(Date.now() - days * DAY);
const rows: Row[] = [];
for await (const page of paginateDescribeRepositories({ client: ecr }, {})) {
for (const repo of page.repositories ?? []) rows.push(await inspect(repo, cutoff));
}
rows.sort((a, b) => Number(b.Unused) - Number(a.Unused) || b.SizeGB - a.SizeGB);
console.table(rows);
const unused = rows.filter((r) => r.Unused && (!only.size || only.has(r.Repository)));
const saving = unused.reduce((sum, r) => sum + r.UsdPerMonth, 0);
console.log(`${rows.length} repositories in ${region}; ${unused.length} with no push or pull in ${days} days, about $${saving.toFixed(2)}/month of storage.`);
if (!apply) {
console.log("Report only: nothing was deleted. Add --apply --older-than <days> to delete the unused repositories.");
return;
}
for (const r of unused) {
try {
await ecr.send(new DeleteRepositoryCommand({ repositoryName: r.Repository, force: true }));
console.log(`Deleted ${r.Repository} (${r.Images} image${r.Images === 1 ? "" : "s"}, ${r.SizeGB} GB)`);
} catch (err) {
console.error(`Could not delete ${r.Repository}: ${errText(err)}`);
process.exitCode = 1;
}
}
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ecr
npm install --save-dev tsx typescript @types/node
# Report: repositories with no push or pull in 90 days
AWS_PROFILE=readonly npx tsx find-unused-ecr-repositories.ts --region us-east-1
# Delete repositories untouched for 180 days, limited to three names
AWS_PROFILE=registry-admin npx tsx find-unused-ecr-repositories.ts --region us-east-1 \
--older-than 180 --apply --names ml/feature-builder,hackathon-2025,empty-scratch
Sample output
┌─────────┬──────────────────────┬────────┬────────┬──────────────┬──────────────┬─────────────┬────────┐
│ (index) │ Repository │ Images │ SizeGB │ LastPush │ LastPull │ UsdPerMonth │ Unused │
├─────────┼──────────────────────┼────────┼────────┼──────────────┼──────────────┼─────────────┼────────┤
│ 0 │ 'ml/feature-builder' │ 2 │ 12.4 │ '2026-01-11' │ '2026-01-21' │ 1.24 │ true │
│ 1 │ 'hackathon-2025' │ 1 │ 1.2 │ '2025-09-03' │ 'never' │ 0.12 │ true │
│ 2 │ 'empty-scratch' │ 0 │ 0 │ 'never' │ 'never' │ 0 │ true │
│ 3 │ 'orders-api' │ 2 │ 0.75 │ '2026-09-26' │ '2026-09-27' │ 0.08 │ false │
│ 4 │ 'new-service' │ 0 │ 0 │ 'never' │ 'never' │ 0 │ false │
└─────────┴──────────────────────┴────────┴────────┴──────────────┴──────────────┴─────────────┴────────┘
5 repositories in us-east-1; 3 with no push or pull in 180 days, about $1.36/month of storage.
Deleted ml/feature-builder (2 images, 12.4 GB)
Deleted hackathon-2025 (1 image, 1.2 GB)
Deleted empty-scratch (0 images, 0 GB)
Names are illustrative, from a run in late September 2026. new-service is empty but was created 3 days earlier, so it isn’t flagged. orders-api is active; if its storage keeps growing, it needs a lifecycle policy rather than deletion.
What should you check before deleting a repository?
Deletion is permanent: deleting a repository deletes every image in it, and it can’t be undone. If an image might be needed for a rollback or an audit, pull it and push it to an archive repository first.
- Who still references it. ECS task definitions, Kubernetes manifests, Lambda functions deployed as container images and CI pipelines all hold repository URIs. A reference with no recent pull is often a service that scales from zero or a disaster-recovery path that hasn’t run yet.
- Where it came from. A repository created by a pull through cache rule is created again the next time someone pulls through that rule, so remove or narrow the rule if you don’t want it back.
- Other Regions. Replication copies images to other Regions or accounts, and each destination has its own repository and storage. Run the script in each destination Region.
- The repositories you keep. Once the dead ones are gone, make sure the survivors are scanned: see the script to enable ECR image scanning on every repository.
Unused repositories are one line on a longer cleanup list. The script to clean up unused AMIs and snapshots older than 30 days covers the same pattern for machine images, and finding untagged AWS resources helps you learn who owns a repository before you ask whether it can go.
Troubleshooting
RepositoryNotEmptyException. The repository contains images andforcewasn’t set. The script always sets it; you’d only see this if you removed it.RepositoryNotFoundException. The repository was deleted between listing and deleting, or you’re pointing at the wrong Region or registry.- LastPull says never for an image you use. ECR may not have recorded the pull yet, since it refreshes the timestamp at least once every 24 hours. Treat “never” plus a recent push as active.
- Slow runs on large registries. The script pages through
DescribeImagesfor every repository, so large registries take a while; the guide to AWS SDK v3 paginators explains how the pages are fetched.--nameslimits deletion, not the report.
Ask ChatWithCloud instead
For a quick answer, ask ChatWithCloud “Which ECR repositories in us-east-1 haven’t been pushed to or pulled from in 90 days, and how many GB does each hold?” It writes AWS SDK for JavaScript v2 code, runs it with your profile on your machine and summarizes the results, which is handy when you’re already asking why your AWS bill increased. Use a read-only profile, since ChatWithCloud runs changes without a confirmation step.
Frequently asked questions
How do I find unused ECR repositories?
For each repository, read imagePushedAt and lastRecordedPullTime from DescribeImages and keep the newest of each. If both are older than your threshold, the repository is unused.
Does an empty ECR repository cost money?
No. ECR charges for the storage of images in the repository, so an empty repository has no storage charge.
How accurate is lastRecordedPullTime?
ECR refreshes it at least once every 24 hours. It shows the exact time for images pulled about once a day, and may lag for images pulled more often.
Should I delete the repository or add a lifecycle policy?
Delete repositories nobody uses. For active repositories that grow, add a lifecycle policy that expires old or untagged images instead.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud