How to check file name length
Four numbers disagree about one name: characters, UTF-8 bytes, what one path component holds, and what a database column takes. Run node names.mjs long --count for the first two, sweep the filesystem with growing names for the third, then upload the name and read back what the store kept. Here the filesystem stopped at 255 units.
Why check this
A file name has no single length. The user counts characters, the wire carries UTF-8 bytes, the filesystem counts UTF-16 code units, and the database column counts something else again. A name legal by one count is refused or cut by another.
Run it before sign-off on any endpoint that keeps the submitted name, and again when the store moves.
The failure it prevents is a silent collision. Two names differing only after the cut point become one stored file, the second overwriting the first, both answered 200. Step 10 reproduces it: two invoices in, one file out, January gone.
Everything runs against a local target inside one scratch directory you delete afterwards.
Prerequisites
- Node 22.
node:sqlitewarns there unless you pass--no-warnings. - curl 8.0 or later, for the
;filename=suffix on-F, which sets the part name rather than the file on disk. See the curl manual. - This run was Windows 11 build 22631, NTFS, long paths enabled. Rerun steps 2 and 3 on your deployment target.
names.mjs holds the fixtures, so every step sends one name.
const stem = 'quarterly-report-northern-region-2026-q3-café-approved-draft-';
const names = {
long: stem + '\u{1f9fe}' + '-scanned-by-the-regional-finance-office.pdf',
huge: 'a'.repeat(5000) + '.pdf',
january: stem + '\u{1f9fe}' + '-january-revision-1.pdf',
february: stem + '\u{1f9fe}' + '-february-revision-2.pdf',
};
const name = names[process.argv[2]] ?? process.argv[2];
if (process.argv[3] !== '--count') { process.stdout.write(name); process.exit(0); }
const bytes = (s) => Buffer.byteLength(s, 'utf8');
console.log(name);
console.log('UTF-16 units ' + name.length);
console.log('code points ' + [...name].length);
console.log('UTF-8 bytes ' + bytes(name));
console.log('NFD units ' + name.normalize('NFD').length);
console.log('NFD bytes ' + bytes(name.normalize('NFD')));
console.log('percent-encoded ' + encodeURIComponent(name).length);
fslimit.mjs writes growing names until the filesystem refuses one.
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import path from 'node:path';
const dir = path.resolve(process.argv[2]);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
const alphabet = { 'a': 'a', 'e-acute': 'é', 'receipt-emoji': '\u{1f9fe}' };
for (const [label, ch] of Object.entries(alphabet)) {
let last = 0, failed = null, code = '';
for (let n = 1; n <= 300; n += 1) {
const name = ch.repeat(n);
try {
writeFileSync(path.join(dir, name), 'x');
rmSync(path.join(dir, name));
last = n;
} catch (e) { failed = n; code = e.code; break; }
}
const name = ch.repeat(last);
console.log(label.padEnd(14)
+ 'longest written ' + String(last).padStart(3)
+ ' UTF-16 units ' + String(name.length).padStart(3)
+ ' UTF-8 bytes ' + String(Buffer.byteLength(name)).padStart(3)
+ ' first failure at ' + failed + ' ' + code);
}
rmSync(dir, { recursive: true, force: true });
pathlimit.mjs nests directories past 300 characters.
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import path from 'node:path';
const root = path.resolve(process.argv[2] ?? './deep');
rmSync(root, { recursive: true, force: true });
mkdirSync(root, { recursive: true });
let dir = root;
while (dir.length < 300) {
dir = path.join(dir, 'd'.repeat(40));
mkdirSync(dir);
}
const file = path.join(dir, 'f'.repeat(255));
writeFileSync(file, 'x');
writeFileSync('longpath.txt', file);
console.log('directory path ' + dir.length + ' characters');
console.log('full path ' + file.length + ' characters');
console.log('node sees it ' + existsSync(file));
target.mjs cuts the name by the rule in ?mode=, writes it, and answers with both counts. Each stored file holds the name that won.
import { createServer } from 'node:http';
import { mkdirSync, writeFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
const STORE = path.resolve(process.argv[2] ?? './uploads');
mkdirSync(STORE, { recursive: true });
const cut = {
none: (n) => n,
chars: (n) => n.slice(0, 64),
bytes: (n) => Buffer.from(n, 'utf8').subarray(0, 64).toString('utf8'),
};
createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:8933');
res.writeHead(200, { 'content-type': 'application/json' });
if (req.method === 'GET') {
const rows = readdirSync(STORE).map((n) => ({ name: n, chars: n.length, bytes: Buffer.byteLength(n) }));
return res.end(JSON.stringify(rows, null, 1) + '\n');
}
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
const sent = (/filename="([^"]*)"/.exec(body) ?? [, ''])[1];
let stored = null, error = null;
try {
stored = (cut[url.searchParams.get('mode') ?? 'none'] ?? cut.none)(sent);
writeFileSync(path.join(STORE, stored), sent);
} catch (e) { error = e.code ?? e.message; stored = null; }
res.end(JSON.stringify({
sentChars: sent.length, sentBytes: Buffer.byteLength(sent), stored,
storedChars: stored?.length ?? null, storedBytes: stored === null ? null : Buffer.byteLength(stored), error,
}) + '\n');
});
}).listen(8933, '127.0.0.1', () => console.log('upload target on 8933, store ' + STORE));
twins.mjs sends two names sharing their first 64 units, then lists the store.
import { readdirSync, readFileSync, rmSync } from 'node:fs';
import { execSync } from 'node:child_process';
import path from 'node:path';
const STORE = path.resolve(process.argv[2] ?? './uploads');
for (const f of readdirSync(STORE)) rmSync(path.join(STORE, f));
for (const fixture of ['january', 'february']) {
const name = execSync('node names.mjs ' + fixture).toString();
const form = new FormData();
form.append('file', new Blob(['payload']), name);
const res = await fetch('http://127.0.0.1:8933/?mode=chars', { method: 'POST', body: form });
const body = await res.json();
console.log('sent ' + res.status + ' ...' + name.slice(60) + ' (' + name.length + ' units)');
console.log('stored ...' + body.stored.slice(60) + ' (' + body.storedChars + ' units)');
}
for (const f of readdirSync(STORE)) {
console.log('store ...' + f.slice(60) + ' holds ...' + readFileSync(path.join(STORE, f), 'utf8').slice(60));
}
column.mjs stores the name in a declared VARCHAR(32) and in a checked column.
import { DatabaseSync } from 'node:sqlite';
import { execSync } from 'node:child_process';
const name = execSync('node names.mjs long').toString();
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE files (id INTEGER PRIMARY KEY, original_name VARCHAR(32))');
db.exec('CREATE TABLE guarded (id INTEGER PRIMARY KEY, original_name TEXT CHECK (length(original_name) <= 32))');
db.prepare('INSERT INTO files (original_name) VALUES (?)').run(name);
const row = db.prepare('SELECT length(original_name) AS chars, length(CAST(original_name AS BLOB)) AS bytes FROM files').get();
console.log('VARCHAR(32) accepted the name');
console.log(' sqlite length() ' + row.chars);
console.log(' sqlite length() as blob ' + row.bytes);
console.log(' javascript name.length ' + name.length);
try {
db.prepare('INSERT INTO guarded (original_name) VALUES (?)').run(name);
console.log('CHECK constraint accepted the name');
} catch (e) {
console.log('CHECK constraint refused: ' + e.message);
}
Write a payload file and start the target.
printf 'payload\n' > payload.txt && node target.mjs ./uploads
Steps 5 and 7 to 9 carry the name in a config file, because the command line loses it (step 6). Define send once.
send() { printf 'form = "file=@payload.txt;filename=%s"\nurl = "http://127.0.0.1:8933/?mode=%s"\nsilent\n' "$(node names.mjs "$1")" "$2" > upload.conf; curl -K upload.conf; }
Steps
- Step 1.
Count the test name every way a system counts it.
node names.mjs long --countquarterly-report-northern-region-2026-q3-café-approved-draft-🧾-scanned-by-the-regional-finance-office.pdf UTF-16 units 106 code points 105 UTF-8 bytes 109 NFD units 107 NFD bytes 110 percent-encoded 121Six counts of one name, five of them different. Name the unit before the number.
- Step 2.
Find the longest name the filesystem accepts, for three characters.
node fslimit.mjs ./fs-probea longest written 255 UTF-16 units 255 UTF-8 bytes 255 first failure at 256 ENOENT e-acute longest written 255 UTF-16 units 255 UTF-8 bytes 510 first failure at 256 ENOENT receipt-emoji longest written 127 UTF-16 units 254 UTF-8 bytes 508 first failure at 128 ENOENTThe unit is the UTF-16 code unit, not the byte. 255 accented letters are 510 bytes and fit; 128 emoji are 256 units and do not.
- Step 3.
Push the whole path past 260 characters.
node pathlimit.mjs ./deepdirectory path 317 characters full path 573 characters node sees it trueThe name and the path holding it are two separate limits.
- Step 4.
Read the setting that decides whether the 260-character limit applies.
(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem').LongPathsEnabled1That is why step 3 succeeded. At
0the same upload fails on path length alone. - Step 5.
Send a name longer than any store accepts.
send huge none{"sentChars":5004,"sentBytes":5004,"stored":null,"storedChars":null,"storedBytes":null,"error":"ENOENT"}The part name rides in the body, so 5004 characters arrived.
ENOENTis what the filesystem answers a name too long. - Step 6.
Send the name as a command-line argument and trace the wire.
curl -s --trace-ascii - -F "file=@payload.txt;filename=$(node names.mjs long)" 'http://127.0.0.1:8933/?mode=none'=> Send data, 309 bytes (0x135) 0000: --------------------------2k8VKkk8R9uLHDOKOjboJD 0032: Content-Disposition: form-data; name="file"; filename="quarterly 0072: -report-northern-region-2026-q3-cafe-approved-draft-??-scanned-b 00b2: y-the-regional-finance-office.pdf" … 0004: {"sentChars":106,"sentBytes":106,"stored":null,"storedChars":nul 0044: l,"storedBytes":null,"error":"ENOENT"}.The request carried
cafeand two question marks, so 109 bytes became 106 and the write failed. Node read the same argument whole. Measure what arrives. - Step 7.
Send the same name from a config file, which the shell leaves alone.
send long none{"sentChars":106,"sentBytes":109,"stored":"quarterly-report-northern-region-2026-q3-café-approved-draft-🧾-scanned-by-the-regional-finance-office.pdf","storedChars":106,"storedBytes":109,"error":null}106 units and 109 bytes preserved, the baseline the next two steps cut.
- Step 8.
Cut the name to 64 bytes, the rule a byte-counted column invites.
send long bytes{"sentChars":106,"sentBytes":109,"stored":"quarterly-report-northern-region-2026-q3-café-approved-draft-�","storedChars":62,"storedBytes":65,"error":null}The cut fell inside the emoji. Its two surviving bytes decoded to one replacement character of three bytes, so a 64-byte rule stored 65.
- Step 9.
Cut the same name to 64 characters instead.
send long chars{"sentChars":106,"sentBytes":109,"stored":"quarterly-report-northern-region-2026-q3-café-approved-draft-🧾-","storedChars":64,"storedBytes":67,"error":null}No broken character, and no extension. The stored name is 67 bytes, past a 64-byte column.
- Step 10.
Send two names that differ only after the cut point.
node twins.mjs ./uploadssent 200 ...-🧾-january-revision-1.pdf (86 units) stored ...-🧾- (64 units) sent 200 ...-🧾-february-revision-2.pdf (87 units) stored ...-🧾- (64 units) store ...-🧾- holds ...-🧾-february-revision-2.pdfTwo accepted uploads, one file. February overwrote January, and no status code says so.
- Step 11.
Ask the database what the declared column length does.
node --no-warnings column.mjsVARCHAR(32) accepted the name sqlite length() 105 sqlite length() as blob 109 javascript name.length 106 CHECK constraint refused: CHECK constraint failed: length(original_name) <= 32SQLite ignored the declared length and counted the row three ways. A length test here says nothing about a column that enforces one.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Characters and bytes agree in step 1 | The name is ASCII only | Retest with an accented letter and an emoji. The counts separate only there. |
| The store kept fewer units than were sent | Something truncates | Find the layer that cut, and set the limit there deliberately with a documented number. |
| A stored name ends in the replacement character | The cut split a multi-byte character, as in step 8 | Move the cut to a character boundary, or reject instead of cutting. |
| A stored name lost its extension | The cut ran from the front, as in step 9 | Keep the extension and cut the stem, or store a generated name and keep the original as metadata. |
| Two uploads, one file | Truncation made two names equal | Raise it as data loss, not as a cosmetic defect. Step 10 is the reproduction. |
| ENOENT on a long name | The filesystem refused the component | Validate the length at the edge and answer 400 with the limit in the message. |
| The database accepted a name longer than the column | That engine does not enforce the declared length | Add a CHECK, and rerun the check against the production engine. |
Common mistakes
Thresholds
What to check next
- How to check path traversal in an uploaded file name: the same field steering the write.
- How to test a unicode file name in content disposition: the characters behind the byte count.
- How to check content disposition header: where the stored name comes back out.
- How to test multipart form data: the request shape every step sends.
- How to check sha256 of a file: the content check a rename survives.
FAQ
What is the file name length limit?
There are at least four. Here one path component held 255 UTF-16 code units, the whole path 573 characters, HTTP carried 5004, and the column enforced nothing. The smallest in your stack wins.
How do I count the length of a file name?
Count units, code points and UTF-8 bytes, and record all three, as step 1 does: one name measured 106, 105 and 109. A limit naming only "length" is ambiguous outside ASCII.
Should a service truncate a long name or reject it?
Reject at the edge with a 400 and the number. Truncation collides: step 10 turned two uploads into one file. Where a name must shrink, store a generated identifier and keep the submitted one as metadata.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2node:sqlite 3.51.3PowerShell 5.1.22621.6133Windows 11 build 22631, NTFS
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny