building a file URL with a URL library percent-encodes the backslashes, so the database that exists cannot be opened
env paths · case
Symptom
Section titled “Symptom”A connection string built from a real path fails on Windows and only on Windows. SQLite says the file is missing; the file is right there.
unable to open database filePrinting the DSN shows why, once you look closely:
file:C%3A%5CUsers%5Cme%5Cstate.sqlite?mode=roEvery separator is %5C and the drive colon is %3A. The URL is well-formed and
points nowhere.
u := url.URL{Scheme: "file", Path: `C:\Users\me\state.sqlite`}u.String() // file:C:%5CUsers%5Cme%5Cstate.sqliteAnd the POSIX input that hides it, because it needs no conversion:
u := url.URL{Scheme: "file", Path: "/home/me/state.sqlite"}u.String() // file:///home/me/state.sqliteWhich library you use decides whether you meet this at all. Checked on Node v24.17.0:
const u = new URL("file:");u.pathname = "C:\\Users\\me\\state.sqlite";u.href; // "file:///C:/Users/me/state.sqlite" — WHATWG converts it for youThat is not a reason to relax. It means the same logic is correct in one language and broken in another, so a port, a rewrite, or a second service in a different stack acquires the bug silently.
A backslash is an ordinary character in a URL path, not a separator, so a
general-purpose URL type percent-encodes it as data. Go’s net/url does exactly
that, and it is right to: it was handed a string that was never a URL path.
The WHATWG URL standard carves out an exception — for special schemes, file:
among them, a backslash is treated as a forward slash — which is why browser-shaped
implementations like Node’s URL quietly do the right thing. Go’s net/url,
Python’s urllib.parse.urlunparse, and most DSN builders follow the RFC rather
than that living standard, so they do not.
Two things have to happen for a Windows path to become a valid file URL, and a generic builder does neither:
- Separators must be converted to forward slashes BEFORE the value reaches the URL type, or they are encoded as data.
- A drive-absolute path needs a leading slash, because
file:plusC:/...yields two slashes where the form wants three.file:///C:/...is correct.
Workaround
Section titled “Workaround”Use the purpose-built conversion when your runtime has one — Node’s
pathToFileURL and Python’s pathlib.Path.as_uri() both produce the correct
form, including percent-encoding characters that are legal in a path and special
in a URL:
const { pathToFileURL } = require("node:url");pathToFileURL("C:\\Users\\me\\state.sqlite").href;// "file:///C:/Users/me/state.sqlite"When the target is a DSN rather than a plain URL — a SQLite connection string with query parameters, say — normalize first and build second:
normalized := strings.ReplaceAll(path, `\`, "/")if len(normalized) >= 2 && normalized[1] == ':' { normalized = "/" + normalized // file:///C:/...}u := url.URL{Scheme: "file", Path: normalized}Then assert on the result in a test: a DSN containing %5C is always wrong, and
that one check catches every future call site — including the one someone adds
next year in a different language.
dynamic-import-needs-file-url is the loud version of the same confusion, where
a loader refuses a path outright because the drive letter reads as a protocol.
This is the quiet version, and a different mechanism underneath: nothing is
refused, the conversion succeeds, and the separators simply become data.