costwatch
Find wasted AWS spend, and who left it running.
costwatch scans an AWS account for idle and orphaned resources, estimates what each one costs
per month, and (with --owners) works out who owns it from tags or CloudTrail.
$ costwatch scan --owners # example output, abridged
Account 123456789012
$/month Check Region Resource Details Owner
50.00 unattached-ebs-volume eu-west-1 old-db-data 500 GiB gp2 volume not attached to any… alice@example.com (AWSReservedSSO_Admin)
16.43 idle-load-balancer eu-west-1 staging-alb application load balancer has no regis… deploy-bot
9.50 old-rds-snapshot eu-west-1 pre-upgrade 100 GiB manual snapshot of postgres in… dba-team (tag:Team)
3.65 unused-elastic-ip us-east-1 eipalloc-… Elastic IP 3.250.1.10 is not associated… unknown
Waste by owner
alice@example.com (AWSReservedSSO_Admin) 1 50.00
deploy-bot 1 16.43
...
Total: ~$79.58/month (~$955/year) across 17 region(s)
- Read-only. It only calls
Describe*/Lookup*APIs. Runcostwatch policyto see exactly what it needs. - Local. It runs on your machine with your credentials. Nothing is sent anywhere.
Install
Requires Python 3.12+.
uv tool install git+https://github.com/jonmelia/costwatch
# or
pipx install git+https://github.com/jonmelia/costwatch
Usage
costwatch scan # all enabled regions, default credentials / $AWS_PROFILE
costwatch scan --profile prod --region eu-west-1 --region us-east-1
costwatch scan --owners # add who owns each resource
costwatch scan --min-cost 5 # hide findings under $5/month
costwatch scan --snapshot-age-days 180 --stopped-days 60
costwatch scan --idle-days 30 # look at 30 days of CloudWatch metrics
costwatch scan --json > findings.json # machine-readable
costwatch policy # IAM policy for a read-only role
If a check isn't permitted or a region is disabled, it's reported at the end and the rest of the scan carries on.
Checks
| Check | Flags | Estimated cost |
|---|---|---|
unattached-ebs-volume |
EBS volumes not attached to an instance | Storage + provisioned IOPS/throughput |
old-ebs-snapshot |
EBS snapshots older than 90 days that no AMI uses | Upper bound (snapshots are incremental) |
gp2-volume |
Attached gp2 volumes | Saving from switching to gp3 at the same performance |
unused-elastic-ip |
Elastic IPs not associated with anything | $0.005/hour |
long-stopped-instance |
Instances stopped for 30+ days | Their attached EBS volumes |
idle-ec2-instance |
Running instances whose CPU never went above 5% in 14 days | Linux on-demand instance price |
unused-ami |
Your AMIs older than 90 days that no instance or launch template uses | Their snapshots (upper bound) |
idle-load-balancer |
ALB/NLB/GWLB/Classic with no registered targets | Hourly load balancer charge |
idle-nat-gateway |
NAT gateways that sent under 1 MiB in 14 days | $0.045/hour (plus any data) |
old-rds-snapshot |
Manual RDS/Aurora snapshots older than 90 days | Upper bound: allocated size × backup rate |
idle-rds-instance |
RDS instances with no connections in 14 days | Instance (× 2 for Multi-AZ) + storage |
retained-rds-backup |
Automated backups kept after their instance was deleted | Upper bound: allocated size × backup rate |
log-group-no-retention |
CloudWatch log groups over 1 GiB with no retention set | Current storage at $0.03/GB-month |
The idle checks use CloudWatch metrics and are deliberately conservative: an instance only counts as idle if its CPU never went above 5%, and resources with no metric data are skipped.
Prices are us-east-1 on-demand approximations (pricing.py).
They're for ranking waste, not for matching your bill to the cent; other regions are usually
within about 20%. Instance types missing from the price table are still reported, with a note
and a cost of $0.
Owners (--owners)
For each finding, costwatch looks for an owner in this order:
- Tags:
Owner,CreatedBy/created-by/created_by,Contact,Team(any case). - CloudTrail: the identity that created the resource. SSO users show as
user@example.com (PermissionSetRole), IAM users by name.
CloudTrail event history only goes back 90 days, so older resources show as unknown unless
they're tagged. Lookups are rate-limited by AWS (2 per second per region), so this is slower
on accounts with lots of findings.
Development
git clone https://github.com/jonmelia/costwatch && cd costwatch
uv sync
uv run pytest # moto-based, no real AWS calls
uv run ruff check . && uv run ruff format .
tests/e2e/ runs the real costwatch command against a moto
server. To try it by hand against a fake account full of waste:
uv run moto_server -p 5000
uv run python -m tests.e2e.seed --endpoint http://localhost:5000
export AWS_ACCESS_KEY_ID=testing AWS_SECRET_ACCESS_KEY=testing
AWS_ENDPOINT_URL=http://localhost:5000 \
uv run costwatch scan --region eu-west-1 --snapshot-age-days 0 --stopped-days 0 --min-cost 1
Adding a check
- Write a function
(session, region, config) -> list[Finding]insrc/costwatch/checks/and add it toALL_CHECKS. - Add any new permissions to
iam.py. - If it should support
--owners, add its create event(s) toCREATE_EVENTSinowners.py. - Test it with moto.
Contributions welcome: open an issue first for anything large.
License
Metadata
Release files for costwatch 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| costwatch-0.1.0.tar.gz | 20.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| costwatch-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 49.2 kB
Release files / costwatch-0.1.0.tar.gz
| Download URL | costwatch-0.1.0.tar.gz |
|---|---|
| Size | 20.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
27e653e87d87ef55010d5844e7576f33781d2948f0b5351bf3738872c2cbd221
|
|
BLAKE2b-256 checksum How to use checksums |
429a82caebfe96d54ea59e89dcf96203786c58a7578dd7ae1402e96c11d2f34c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / costwatch-0.1.0-py3-none-any.whl
| Download URL | costwatch-0.1.0-py3-none-any.whl |
|---|---|
| Size | 28.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b87435faa54e5b49ae8acb66cf700cf85e370192add60319772141f2ecf35a0d
|
|
BLAKE2b-256 checksum How to use checksums |
4c28c357abb7b01230cb8f7e03bc1fdb8737ae32bbe8b412611114c48fbda30e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log