A # or % in a file or folder name — the classic …Url APIs cannot reach it, and the old writes quietly save %25
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 forplain.txtwhen only its folder has a#or%in the name. - A folder existence check says
Exists: falseabout a folder that exists, so the code creates a second one. - Files and folders appear with
%25in 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: falsefor a folder whose name has#or%, and the code that "creates it because it is missing" then makes a%25twin. - 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 #2024is 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—getfolderbyserverrelativeurl200s for a missing folder. Move and copy return no usable body —movetosucceeds and your code reports a failure. - The same two-layer escaping applies to
$filtervalues — An unencoded#in a$filtervalue 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.