
Photo by Jakub Zerdzicki on Pexels
An idle FSx file system is one that no client has read from or written to for weeks, yet still bills for provisioned storage and throughput every hour. Find them with DescribeFileSystems from @aws-sdk/client-fsx, then sum the DataReadBytes, DataWriteBytes and MetadataOperations metrics in the AWS/FSx namespace for each FileSystemId. All four FSx types publish them.
Amazon FSx gives you managed Windows File Server, Lustre, NetApp ONTAP and OpenZFS file systems. You size storage and throughput up front and pay for them whether anyone mounts the share or not, and the FSx API has no action to stop a file system. A migration test, a Lustre file system from a finished training run or a Windows share for a team that moved on can sit there for months.
This example is for storage and platform engineers who want to find each idle FSx file system, see its last backup and put a monthly price on it. The script scans the Regions you pass and only reports. It never creates a backup or deletes anything, because deleting a file system also deletes its automatic backups.
What does an idle FSx file system cost?
You pay for provisioned storage per GB-month and, for Windows, ONTAP and OpenZFS, for throughput capacity per MBps-month. Lustre folds throughput into the storage rate. As of September 2026, the AWS Price List (published 11 September 2026) shows these rates in US East (N. Virginia):
| File system and deployment | SSD storage per GB-month | Throughput per MBps-month |
|---|---|---|
| Windows, Single-AZ | $0.13 (HDD $0.013) | $2.20 |
| Windows, Multi-AZ | $0.23 (HDD $0.025) | $4.50 |
| Lustre, Persistent 2 at 125 MB/s per TiB | $0.145 | Included |
| Lustre, Persistent 2 at 1,000 MB/s per TiB | $0.60 | Included |
| ONTAP, Single-AZ 1 | $0.125 | $0.72 |
| ONTAP, Multi-AZ 1 | $0.25 | $1.20 |
| OpenZFS, Single-AZ 1 and 2 | $0.09 | $0.26 |
| OpenZFS, Multi-AZ | $0.18 | $0.87 |
| Backups (all four types) | $0.05 per GB-month | |
The script’s estimate covers storage and throughput only. Provisioned SSD IOPS, ONTAP capacity pool storage and requests, backups and Intelligent-Tiering are extra and vary by Region, so treat the figure as a floor.
Worked example: a Windows Single-AZ 2 file system with 1,024 GiB of SSD and 64 MBps of throughput costs 1,024 × $0.13 = $133.12 plus 64 × $2.20 = $140.80, or $273.92 a month. A 2,400 GiB Lustre Persistent 2 file system at 125 MB/s per TiB costs 2,400 × $0.145 = $348.00. An ONTAP Multi-AZ 1 file system with 1,024 GiB and 128 MBps costs 1,024 × $0.25 + 128 × $1.20 = $409.60.
How do you decide an FSx file system is idle?
DataReadBytes and DataWriteBytes measure client I/O, the traffic between the file system and whatever mounts it, with the FileSystemId dimension and the Sum statistic. MetadataOperations counts lookups, listings and similar calls. The FSx for Windows docs list background activity such as shadow copies and Multi-AZ replication under NetworkThroughputUtilization, not under the client metrics, so it doesn’t make an unused share look busy.
The script sums all three over --days (default 14) and labels each file system:
- IDLE: no client I/O when reads, writes and metadata operations are all zero.
- IDLE: under N GiB of I/O when reads plus writes stay below
--min-gib(default 1). A monitoring agent that lists a directory now and then lands here. - too new to judge for file systems younger than the window, and skipped for any that aren’t
AVAILABLE.
It also shows the newest AVAILABLE backup from DescribeBackups, so you know whether a restore point exists before anyone talks about deleting.
What does the script do?
- Lists file systems
paginateDescribeFileSystemsreturns type, deployment, storage type and capacity, throughput and theNametag for all four FSx types. - Reads client I/OOne
GetMetricDatarequest per file system with dailySumofDataReadBytes,DataWriteBytesandMetadataOperations. - Finds the newest backup
paginateDescribeBackupswith thefile-system-idfilter. - Prices itStorage plus throughput from the rate tables in the script, or “unpriced config” for combinations it doesn’t know.
- ReportsA table, a monthly total for idle file systems and an optional CSV.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-fsxand@aws-sdk/client-cloudwatch. - A read-only AWS profile, set up as in the guide to choosing an AWS SDK v3 credentials provider.
- The Regions to scan. Finding your most expensive AWS service with Cost Explorer shows whether FSx is worth the hunt.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadFsxAndMetrics",
"Effect": "Allow",
"Action": [
"fsx:DescribeFileSystems",
"fsx:DescribeBackups",
"cloudwatch:GetMetricData"
],
"Resource": "*"
}
]
}
The script doesn’t call fsx:DeleteFileSystem or fsx:CreateBackup, so a report role never needs them. If you add actions, the free IAM policy generator for TypeScript code lists what the new calls require.
The script to find idle FSx file systems
// find-idle-fsx-file-systems.ts
// Lists Amazon FSx file systems (Windows File Server, Lustre, NetApp ONTAP, OpenZFS) with their size,
// client reads and writes over the last N days, their newest backup and an estimated monthly cost.
// Report only: it never creates backups or deletes anything.
// Usage: npx tsx find-idle-fsx-file-systems.ts [--regions us-east-1,eu-west-1] [--days 14] [--min-gib 1] [--csv fsx.csv]
import { writeFileSync } from "node:fs";
import { FSxClient, paginateDescribeBackups, paginateDescribeFileSystems, type FileSystem } from "@aws-sdk/client-fsx";
import { CloudWatchClient, paginateGetMetricData } from "@aws-sdk/client-cloudwatch";
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 regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Number(flag("--days") ?? 14);
const minGiB = Number(flag("--min-gib") ?? 1); // client reads + writes below this over the window count as idle
const csvPath = flag("--csv");
const GIB = 1024 ** 3;
// us-east-1 USD. AWS Price List (AmazonFSx), published 11 September 2026.
// [storage per GB-month, throughput per MBps-month]; Lustre throughput is included in the storage rate.
const WINDOWS: Record<string, Record<string, [number, number]>> = {
SINGLE_AZ_1: { SSD: [0.13, 2.2], HDD: [0.013, 2.2] },
SINGLE_AZ_2: { SSD: [0.13, 2.2], HDD: [0.013, 2.2] },
MULTI_AZ_1: { SSD: [0.23, 4.5], HDD: [0.025, 4.5] },
};
const ONTAP: Record<string, [number, number]> = { // SSD tier only; capacity pool usage is billed separately
SINGLE_AZ_1: [0.125, 0.72],
SINGLE_AZ_2: [0.125, 1.6],
MULTI_AZ_1: [0.25, 1.2],
MULTI_AZ_2: [0.25, 2.5],
};
const OPENZFS: Record<string, [number, number]> = {
SINGLE_AZ_1: [0.09, 0.26],
SINGLE_AZ_2: [0.09, 0.26],
SINGLE_AZ_HA_1: [0.09, 0.52],
SINGLE_AZ_HA_2: [0.09, 0.52],
MULTI_AZ_1: [0.18, 0.87],
};
const LUSTRE: Record<string, number> = { // "<deployment>/<storage type>/<MB/s per TiB>" -> per GB-month
"PERSISTENT_1/SSD/50": 0.14,
"PERSISTENT_1/SSD/100": 0.19,
"PERSISTENT_1/SSD/200": 0.29,
"PERSISTENT_1/HDD/12": 0.025,
"PERSISTENT_1/HDD/40": 0.083,
"PERSISTENT_2/SSD/125": 0.145,
"PERSISTENT_2/SSD/250": 0.21,
"PERSISTENT_2/SSD/500": 0.34,
"PERSISTENT_2/SSD/1000": 0.6,
};
interface Row {
Region: string;
FileSystem: string;
Name: string;
Type: string;
Deployment: string;
Storage: string;
Throughput: string;
ReadGiB: number;
WriteGiB: number;
MetadataOps: number;
LastBackup: string;
PerMonth: string;
Verdict: string;
}
function deploymentOf(fs: FileSystem): string {
return fs.WindowsConfiguration?.DeploymentType ?? fs.LustreConfiguration?.DeploymentType
?? fs.OntapConfiguration?.DeploymentType ?? fs.OpenZFSConfiguration?.DeploymentType ?? "";
}
function throughputOf(fs: FileSystem): string {
if (fs.FileSystemType === "LUSTRE") {
const perTiB = fs.LustreConfiguration?.PerUnitStorageThroughput;
return perTiB ? `${perTiB} MB/s/TiB` : "-";
}
const mbps = fs.WindowsConfiguration?.ThroughputCapacity ?? fs.OntapConfiguration?.ThroughputCapacity
?? fs.OpenZFSConfiguration?.ThroughputCapacity;
return mbps ? `${mbps} MBps` : "-";
}
/** Storage plus throughput capacity for a 730-hour month; excludes IOPS, backups, capacity pools and requests. */
function monthlyCost(fs: FileSystem): string {
const gb = fs.StorageCapacity ?? 0;
const deployment = deploymentOf(fs);
const storageType = fs.StorageType ?? "SSD";
let rate: [number, number] | undefined;
let mbps = 0;
if (fs.FileSystemType === "WINDOWS") {
rate = WINDOWS[deployment]?.[storageType];
mbps = fs.WindowsConfiguration?.ThroughputCapacity ?? 0;
} else if (fs.FileSystemType === "ONTAP" && storageType === "SSD") {
rate = ONTAP[deployment];
mbps = fs.OntapConfiguration?.ThroughputCapacity ?? 0;
} else if (fs.FileSystemType === "OPENZFS" && storageType === "SSD") {
rate = OPENZFS[deployment];
mbps = fs.OpenZFSConfiguration?.ThroughputCapacity ?? 0;
} else if (fs.FileSystemType === "LUSTRE") {
const perGb = LUSTRE[`${deployment}/${storageType}/${fs.LustreConfiguration?.PerUnitStorageThroughput ?? 0}`];
if (perGb !== undefined) rate = [perGb, 0];
}
if (!rate) return "unpriced config";
return `$${(Math.round((gb * rate[0] + mbps * rate[1]) * 100) / 100).toFixed(2)}`;
}
/** Sums of client read bytes, write bytes and metadata operations over the window. */
async function clientIo(cw: CloudWatchClient, id: string): Promise<Record<string, number>> {
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
const totals: Record<string, number> = { read: 0, write: 0, meta: 0 };
const q = (qid: string, metric: string) => ({
Id: qid,
MetricStat: {
Metric: { Namespace: "AWS/FSx", MetricName: metric, Dimensions: [{ Name: "FileSystemId", Value: id }] },
Period: 86_400,
Stat: "Sum",
},
});
for await (const page of paginateGetMetricData({ client: cw }, {
StartTime: start,
EndTime: end,
MetricDataQueries: [q("read", "DataReadBytes"), q("write", "DataWriteBytes"), q("meta", "MetadataOperations")],
})) {
for (const r of page.MetricDataResults ?? []) {
const key = r.Id ?? "";
totals[key] = (totals[key] ?? 0) + (r.Values ?? []).reduce((a, b) => a + b, 0);
}
}
return totals;
}
async function newestBackup(fsx: FSxClient, id: string): Promise<string> {
let newest: Date | undefined;
for await (const page of paginateDescribeBackups({ client: fsx }, { Filters: [{ Name: "file-system-id", Values: [id] }] })) {
for (const b of page.Backups ?? []) {
if (b.Lifecycle === "AVAILABLE" && b.CreationTime && (!newest || b.CreationTime > newest)) newest = b.CreationTime;
}
}
return newest ? newest.toISOString().slice(0, 10) : "none";
}
async function scanRegion(region: string): Promise<Row[]> {
const fsx = new FSxClient({ region });
const cw = new CloudWatchClient({ region });
const rows: Row[] = [];
for await (const page of paginateDescribeFileSystems({ client: fsx }, {})) {
for (const fs of page.FileSystems ?? []) {
const id = fs.FileSystemId ?? "";
const io = await clientIo(cw, id);
const readGiB = (io.read ?? 0) / GIB;
const writeGiB = (io.write ?? 0) / GIB;
const metaOps = io.meta ?? 0;
const ageDays = fs.CreationTime ? (Date.now() - fs.CreationTime.getTime()) / 86_400_000 : 0;
let verdict = "in use";
if (fs.Lifecycle !== "AVAILABLE") verdict = `skipped: ${fs.Lifecycle ?? "unknown"}`;
else if (ageDays < days) verdict = "too new to judge";
else if (readGiB + writeGiB === 0 && metaOps === 0) verdict = "IDLE: no client I/O";
else if (readGiB + writeGiB < minGiB) verdict = `IDLE: under ${minGiB} GiB of I/O`;
rows.push({
Region: region,
FileSystem: id,
Name: fs.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
Type: fs.FileSystemType ?? "",
Deployment: deploymentOf(fs),
Storage: `${fs.StorageCapacity ?? 0} GiB ${fs.StorageType ?? ""}`.trim(),
Throughput: throughputOf(fs),
ReadGiB: Math.round(readGiB * 100) / 100,
WriteGiB: Math.round(writeGiB * 100) / 100,
MetadataOps: Math.round(metaOps),
LastBackup: await newestBackup(fsx, id),
PerMonth: monthlyCost(fs),
Verdict: verdict,
});
}
}
return rows;
}
function toCsv(rows: Row[]): string {
const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}
async function main(): Promise<void> {
if (!Number.isInteger(days) || days < 1 || days > 455) throw new Error("--days must be a whole number from 1 to 455");
if (!Number.isFinite(minGiB) || minGiB < 0) throw new Error("--min-gib must be 0 or more");
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
}
}
if (rows.length === 0) {
console.log(`No FSx file systems in ${regions.join(", ")}.`);
return;
}
console.table(rows);
const idle = rows.filter((r) => r.Verdict.startsWith("IDLE"));
const monthly = idle.reduce((s, r) => s + (Number(r.PerMonth.replace("$", "")) || 0), 0);
console.log(`${idle.length} of ${rows.length} file systems idle for ${days} days: about $${monthly.toFixed(2)} a month (us-east-1 prices).`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-fsx @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node
# Two Regions, 14 days, CSV for the storage team
AWS_PROFILE=readonly npx tsx find-idle-fsx-file-systems.ts --regions us-east-1,eu-central-1 --csv idle-fsx.csv
# A 30-day window, treating anything under 5 GiB of I/O as idle
AWS_PROFILE=readonly npx tsx find-idle-fsx-file-systems.ts --days 30 --min-gib 5
Sample output
┌─────────┬─────────────┬────────────────────────┬───────────────────────┬───────────┬────────────────┬────────────────┬────────────────┬─────────┬──────────┬─────────────┬──────────────┬───────────┬────────────────────────────┐
│ (index) │ Region │ FileSystem │ Name │ Type │ Deployment │ Storage │ Throughput │ ReadGiB │ WriteGiB │ MetadataOps │ LastBackup │ PerMonth │ Verdict │
├─────────┼─────────────┼────────────────────────┼───────────────────────┼───────────┼────────────────┼────────────────┼────────────────┼─────────┼──────────┼─────────────┼──────────────┼───────────┼────────────────────────────┤
│ 0 │ 'us-east-1' │ 'fs-0a1b2c3d4e5f60001' │ 'finance-share' │ 'WINDOWS' │ 'SINGLE_AZ_2' │ '1024 GiB SSD' │ '64 MBps' │ 83.82 │ 18.63 │ 5000000 │ '2026-09-27' │ '$273.92' │ 'in use' │
│ 1 │ 'us-east-1' │ 'fs-0a1b2c3d4e5f60002' │ 'ml-training-scratch' │ 'LUSTRE' │ 'PERSISTENT_2' │ '2400 GiB SSD' │ '125 MB/s/TiB' │ 0 │ 0 │ 0 │ '2026-09-27' │ '$348.00' │ 'IDLE: no client I/O' │
│ 2 │ 'us-east-1' │ 'fs-0a1b2c3d4e5f60003' │ 'sap-migration-test' │ 'ONTAP' │ 'MULTI_AZ_1' │ '1024 GiB SSD' │ '128 MBps' │ 0.19 │ 0.09 │ 900 │ 'none' │ '$409.60' │ 'IDLE: under 1 GiB of I/O' │
│ 3 │ 'us-east-1' │ 'fs-0a1b2c3d4e5f60004' │ 'build-cache' │ 'OPENZFS' │ 'SINGLE_AZ_2' │ '512 GiB SSD' │ '160 MBps' │ 372.53 │ 279.4 │ 8000000 │ '2026-09-27' │ '$87.68' │ 'in use' │
└─────────┴─────────────┴────────────────────────┴───────────────────────┴───────────┴────────────────┴────────────────┴────────────────┴─────────┴──────────┴─────────────┴──────────────┴───────────┴────────────────────────────┘
2 of 4 file systems idle for 14 days: about $757.60 a month (us-east-1 prices).
This run used mocked AWS responses, so names and numbers are illustrative. ml-training-scratch had no client I/O for 14 days and costs $348 a month; it still has a backup from yesterday. sap-migration-test saw a few hundred MB and has no backup at all, so take one before anything else.
What should you do before deleting an FSx file system?
DeleteFileSystem removes the data and, per the FSx API reference, any existing automatic backups and snapshots. Each type then has its own rules:
| Type | Before you delete |
|---|---|
| Windows File Server | A final backup is created by default. It isn’t subject to the retention policy, so it bills until you delete it. |
| Lustre | Unmount it from every client. No final backup is taken unless you set SkipFinalBackup to false, and backups aren’t available on S3-linked file systems, so export changed data to S3 first. |
| NetApp ONTAP | Delete all volumes and storage virtual machines (SVMs) first. Back up the volumes you want to keep. |
| OpenZFS | A final backup is taken by default. Detach any S3 access points from its volumes. |
A kept backup costs $0.05 per GB-month in US East (N. Virginia), far below the file system itself. If you only need the data for compliance, the comparison in S3 storage class cost for backups, Glacier vs Standard-IA helps decide where a copy should live long term. Tag an owner before you ask around: find untagged AWS resources with the Tagging API shows which file systems nobody claimed.
FSx is one of several storage bills that keep running unnoticed. The scripts to find EFS file systems without a lifecycle policy, find and tag unattached EBS volumes and find orphaned EBS snapshots cover the rest. Windows shares often served virtual desktops, so also find unused Amazon WorkSpaces. The FinOps Framework treats this kind of regular cleanup as part of optimizing usage rather than a one-off project.
Troubleshooting
- PerMonth shows “unpriced config”. The script has no rate for that combination, for example Lustre Scratch, HDD with SSD cache or Intelligent-Tiering. Add it from the Price List or the FSx pricing page for your Region.
- ReadGiB and WriteGiB are 0 for a share you know is used. Check the Region and that the profile has
cloudwatch:GetMetricData; an access error in one Region prints above the table. - LastBackup shows “none” on a Lustre file system. S3-linked Lustre file systems can’t have backups; the data lives in the linked bucket.
- Access denied for one Region. An SCP may block it. Troubleshooting AWS IAM access denied errors step by step explains how to read the message.
Ask ChatWithCloud instead
For a quick check, ask ChatWithCloud “Which FSx file systems had no read or write bytes in the last 14 days, and when were they last backed up?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and explains the result; see how ChatWithCloud answers AWS questions from your terminal. It uses one profile and Region per session, can be wrong, and runs changes without asking first, so connect ChatWithCloud with a read-only AWS profile and delete an idle FSx file system yourself once its owner agrees. The library of AWS cleanup scripts has more reports like this one.
Frequently asked questions
Can you stop an Amazon FSx file system to save money?
No. The FSx API can update, back up and delete a file system but has no stop action. Storage and throughput bill until you delete it or shrink what you can, such as throughput capacity.
Does deleting an FSx file system delete its backups?
The FSx API reference says existing automatic backups and snapshots are deleted with the file system. A final backup taken at deletion isn’t subject to the retention policy and keeps billing until you delete it yourself.
Which CloudWatch metric shows whether anyone uses an FSx file system?
DataReadBytes and DataWriteBytes with the Sum statistic and the FileSystemId dimension, in the AWS/FSx namespace. MetadataOperations catches clients that only list or stat files.
Is it cheaper to keep a backup than an idle file system?
Usually. Backups cost $0.05 per GB-month in US East (N. Virginia) as of September 2026, compared with $0.09 to $0.60 per GB-month for SSD storage plus throughput charges.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud