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

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

  1. Step 1.

    Count the test name every way a system counts it.

    node names.mjs long --count
    
    quarterly-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  121

    Six counts of one name, five of them different. Name the unit before the number.

  2. Step 2.

    Find the longest name the filesystem accepts, for three characters.

    node fslimit.mjs ./fs-probe
    
    a             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 ENOENT

    The 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.

  3. Step 3.

    Push the whole path past 260 characters.

    node pathlimit.mjs ./deep
    
    directory path  317 characters
    full path       573 characters
    node sees it    true

    The name and the path holding it are two separate limits.

  4. Step 4.

    Read the setting that decides whether the 260-character limit applies.

    (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem').LongPathsEnabled
    
    1

    That is why step 3 succeeded. At 0 the same upload fails on path length alone.

  5. 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. ENOENT is what the filesystem answers a name too long.

  6. 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 cafe and two question marks, so 109 bytes became 106 and the write failed. Node read the same argument whole. Measure what arrives.

  7. 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.

  8. 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.

  9. 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.

  10. Step 10.

    Send two names that differ only after the cut point.

    node twins.mjs ./uploads
    
    sent   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.pdf

    Two accepted uploads, one file. February overwrote January, and no status code says so.

  11. Step 11.

    Ask the database what the declared column length does.

    node --no-warnings column.mjs
    
    VARCHAR(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) <= 32

    SQLite 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

Sign: A sanitiser documented as a 64-byte limit stores a name of 65 bytes.Cause: Step 8 is that bug. The cut landed inside a four-byte character, and the two surviving bytes decoded to one replacement character of three bytes, so the result is longer than the cap it was meant to enforce. A byte cut has to move back to a character boundary before it stops.
Sign: The length test passes with 255 characters and the same upload fails in production for a customer writing in another language.Cause: Step 2 measured it: a name of 255 letters and a name of 255 accented letters both fit, while 128 emoji do not. The filesystem counts UTF-16 code units, so one character costs one unit or two. An ASCII fixture cannot reach the limit that bites.
Sign: The test asserts on the name it typed, and the numbers never match what the server logged.Cause: Step 6 typed a 109-byte name into the shell and 106 bytes arrived: the trace shows the accent flattened and the emoji written as two question marks. Node read the same argument whole, so this is per program, not per name. Assert on what the server reports it received, and carry the name in a config file or a request body.
Sign: A length limit is tested against SQLite or an in-memory store, passes, and the real database then rejects the insert.Cause: Step 11 stored a 106-character name in a column declared VARCHAR(32) with no error. That engine does not enforce the declared length, and its length() counts 105 code points where the byte count is 109. Test the limit against the engine you deploy, or add an explicit CHECK constraint.

Thresholds

255 UTF-16 code units for one path component, which is 255 to 510 UTF-8 bytes depending on the characters Source: Measured by fslimit.mjs in step 2, NTFS on Windows 11 build 22631, 2026-09-12
260 characters for a whole path, unless long paths are enabled, in which case a 573-character path was written and read here Source: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
5004 characters in a multipart file name arrived intact over HTTP, so the transport set no usable limit Source: Measured in step 5 against the local target on Node 22.23.2, 2026-09-12

What to check next

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.

intermediate12 minpublished updated Maks Verny