SharePointVault ~/gotchas/rest-api ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ hash-and-percent-in-file-names-need-the-resourcepath-api.md
sharepoint-vault›gotchas›rest-api›hash-and-percent-in-file-names-need-the-resourcepath-api.mdhistoryedit on GitHub
● GOTCHArest-api· 7 min read

A # or % in a file or folder name — the classic …Url APIs cannot reach it, and the old writes quietly save %25

applies to: SharePoint Online REST (any client that builds `/_api/web/…` URLs by hand, SPFx included)reviewed: 2026-09-25#rest-api#files#folders#url-encoding#resourcepath#silent-data-loss

SharePoint Online allows # and % in file and folder names, but the string-based "Url" APIs — GetFileByServerRelativeUrl, GetFolderByServerRelativeUrl, Files/add(url=…), folders/add(…), moveto, copyto — cannot address such an item, whether you encode the path or not. Worse, some of them answer 200 and create a different name with a literal %25 in it, and the folder lookup answers Exists: false for a folder that exists. Use the ResourcePath API (…ByServerRelativePath(decodedurl=…), …UsingPath(DecodedUrl=…)) — and still URL-encode the decoded path, after doubling apostrophes.

SharePoint Online povoluje # a % v názvech souborů a složek, ale řetězcová „Url“ API – GetFileByServerRelativeUrl, GetFolderByServerRelativeUrl, Files/add(url=…), folders/add(…), moveto, copyto – na takovou položku nedosáhnou, ať cestu zakóduješ, nebo ne. Hůř: některá vrátí 200 a založí jiné jméno s doslovným %25 a dotaz na složku vrátí Exists: false u složky, která existuje. Použij ResourcePath API (…ByServerRelativePath(decodedurl=…), …UsingPath(DecodedUrl=…)) – a dekódovanou cestu pro URL stejně zakóduj, až po zdvojení apostrofů.

Symptom

People name files and folders, and # and % are ordinary characters for them: Invoice #12.pdf, Discount 20%.docx, Q3 #2 review.

  • A file that is plainly in the library "does not exist": 404 for the one with #, 400 (or 404) for the one with % — and 404 even for plain.txt when only its folder has a # or % in the name.
  • A folder existence check says Exists: false about a folder that exists, so the code creates a second one.
  • Files and folders appear with %25 in their names after an upload, a folder creation, a move or a copy — and nothing reported an error.
  • A cleanup job deletes a file, gets a 404, books the file as already gone — and the file is still in the library.

Measured

SharePoint Online, 24 September 2026, plain fetch from a browser tab, apostrophes always doubled. Files t-amp & b.txt, t-plus + b.txt, t-apos o'b.txt, t-hash # b.txt, t-pct 50% b.txt in a plain folder, and the same set inside a folder named f #1 & 50% + o'k.

Reading a file

Call &, +, ' # %
GetFileByServerRelativeUrl('…'), raw 200 404 400
same, path through encodeURI or encodeURIComponent 200 404 404
GetFileByServerRelativePath(decodedurl='…'), raw 200 404 400
GetFileByServerRelativePath(decodedurl='…'), path through encodeURIComponent 200 200 200

Inside the folder f #1 & 50% + o'k, every file — t-plain.txt included — returned 404 on every variant except the last one, which returned 200 for all of them.

Looking up that folder

Call Result
GetFolderByServerRelativeUrl('…'), raw 404
GetFolderByServerRelativeUrl('…'), encoded 200 {"Exists": false, "Name": "f #1 & 50%25 + o'k"}
GetFolderByServerRelativePath(decodedurl='…'), encoded 200 {"Exists": true}

Writing

Call Result
Files/add(url='…') with & or + 200
Files/add(url='…') with #: raw / encoded 404 / 200
Files/add(url='…') with %: raw / encoded 400 / 200, but the file is saved as u-pct 50%25 b.txt
folders/add('…'), encoded 200, folder saved as fa #1 50%25 o'k
moveto(newurl='…',flags=1), encoded target 200, target saved with 50%25
copyto(strnewurl='…',boverwrite=true), encoded target 200, target saved with 50%25
folders/AddUsingPath(DecodedUrl='…'), apostrophes doubled but not URL-encoded 404
every …UsingPath call below, path through encodeURIComponent 200, exact name

Cause

Microsoft documents it: the string-based APIs "handle both encoded and decoded URLs by automatically assuming that % and # characters in a path imply that the URL has been encoded". A name that really contains those characters is therefore misread, and a write stores its own reading of it. The fix Microsoft added is the ResourcePath family, which "assumes and only works with decoded URLs" (Supporting % and # in files and folders with the ResourcePath API).

"Decoded" describes the value, not the transport. The value still travels inside a URL, where # starts the fragment (the browser never sends it) and % starts an escape sequence. The REST samples in that article put the decoded path into the URL raw — fine for spaces, broken for exactly the two characters the API exists for.

Fix

Double the apostrophes (OData literal), then URL-encode, and pass the result to the ResourcePath API:

/** OData string literal for a URL, quotes included: double apostrophes first, then encode. */
const pathLiteral = (p: string): string => "'" + encodeURIComponent(p.split("'").join("''")) + "'";

const fileApi   = (web: string, fileUrl: string) =>
  `${web}/_api/web/GetFileByServerRelativePath(decodedurl=${pathLiteral(fileUrl)})`;
const folderApi = (web: string, folderUrl: string) =>
  `${web}/_api/web/GetFolderByServerRelativePath(decodedurl=${pathLiteral(folderUrl)})`;
// read
GET  fileApi(web, url) + '/$value'
GET  folderApi(web, url) + '?$select=Exists'
// write (file name = leaf, folder = server-relative path)
POST folderApi(web, folder) + `/Files/AddUsingPath(DecodedUrl=${pathLiteral(name)},Overwrite=true)`
POST `${web}/_api/web/folders/AddUsingPath(DecodedUrl=${pathLiteral(folderUrl)},Overwrite=true)`
POST folderApi(web, parent) + `/AddSubFolderUsingPath(DecodedUrl=${pathLiteral(leaf)})`
// move / copy (target = server-relative path)
POST fileApi(web, src)   + `/MoveToUsingPath(DecodedUrl=${pathLiteral(dst)},moveOperations=1)`
POST folderApi(web, src) + `/MoveToUsingPath(DecodedUrl=${pathLiteral(dst)})`
POST fileApi(web, src)   + `/CopyToUsingPath(DecodedUrl=${pathLiteral(dst)},bOverWrite=true)`

SP.MoveCopyUtil.MoveFileByPath(overwrite=@a1)?@a1=false with a JSON body { srcPath: { DecodedUrl: <absolute URL> }, destPath: { DecodedUrl: … }, options: { … } } handled the same names correctly too.

Migrating changes behaviour on an existing target (measured the same evening):

Situation Old API ResourcePath API
Create a folder that already exists folders/add → 200 folders/AddUsingPath (no Overwrite or Overwrite=false) → 400 "A file or folder with the name … already exists"; AddSubFolderUsingPath → 500 with the same text; folders/AddUsingPath(…,Overwrite=true) → 200 and the files inside stay
Upload over an existing file without overwrite 400 "A file with the name … already exists" the same 400 and text
Move or copy empty body 200 with {"odata.null": true} — no file object to read; an existing target without overwrite is a 400 "already exists" (folder: Cannot rename "y" to "w": destination already exists.)
Missing file / missing folder 404 / 200 Exists: false 404 "does not exist" / 200 Exists: false — unchanged

So an idempotent "ensure this folder exists" becomes folders/AddUsingPath(…,Overwrite=true); elsewhere treat "already exists" as "it's there". ?$expand=ListItemAllFields&$select=Name,ServerRelativeUrl,UniqueId,ListItemAllFields/Id works on the AddUsingPath response.

Notes

  • The check and the write must use the same API. A legacy existence check reports Exists: false for a folder whose name has # or %, and the code that "creates it because it is missing" then makes a %25 twin.
  • A 404 from the old calls means "not found", not "gone". A cleanup that deleted files through the old calls read a 404 as "already deleted" — but the old calls answer 404 for an existing file with # in its name, and for one with % once the path is encoded (first table, measured on reads). The file stayed in the library and the job counted it as removed. The job built its own file names from slugs, so only a file somebody had put into the library by hand carried a # or % — exactly the one it could not see. Any code that reads 404 as "gone" (a cleanup, a sync, a "delete if present") then acts on the wrong state. Check existence through the same ResourcePath API you write with, the path encoded as in Fix (raw, it answers 404 for # too — first table); a 404 from anything else means "could not tell", the third answer next to "there" and "not there" — "File is missing" vs. "I cannot read it".
  • A special character in any segment breaks the path — a plain file inside Invoices #2024 is unreachable through the old calls.
  • Microsoft lists web/GetFileByUrl(@u) with an absolute, properly encoded URL as unambiguous; we did not measure it here.
  • Existence checks keep their other trap on both APIs: a missing folder is a 200 with Exists: false — getfolderbyserverrelativeurl 200s for a missing folder. Move and copy return no usable body — moveto succeeds and your code reports a failure.
  • The same two-layer escaping applies to $filter values — An unencoded # in a $filter value cuts the URL — and apostrophes are the OData half of it: Apostrophes in OData literals.
  • Review check: search for ServerRelativeUrl(, /files/add(, /folders/add(, /moveto( and /copyto( built from anything a user can name. Test data rarely contains # or %; real document names do.
Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-09-25last updated: 2026-09-25historyedit on GitHubview source