URL vs URI vs URN: What is the Difference
Clear up the relationship between these three identifier specs once and for all.
Developers use "URL" and "URI" interchangeably in conversation, and for casual purposes that is fine. But the distinction is real, it is written into RFC 3986, and misunderstanding the layer below it — the generic syntax and its encoding rules — is what actually causes production bugs, from broken query strings to server-side request forgery.
The Hierarchy in One Paragraph
URI (Uniform Resource Identifier) is the umbrella term for any string that identifies a resource. URL (Uniform Resource Locator) is a URI that also tells you how to reach the resource — it has a locating mechanism, like a scheme and host. URN (Uniform Resource Name) is a URI that names a resource in a location-independent, persistent way. Every URL is a URI; every URN is a URI; no URN is a URL.
URI ─┬─ URL: https://example.com/spec.html
└─ URN: urn:ietf:rfc:3986
The Generic Syntax (RFC 3986)
Every URI decomposes into five components:
https://user@api.example.com:8443/v2/items?sort=asc#top
\___/ \__________________________/\_______/ \______/ \_/
scheme authority path query fragment
- scheme — case-insensitive, canonically lowercase; defines how the rest is interpreted.
- authority — optional userinfo, a host (registered name, IPv4, or bracketed IPv6 like
[::1]), and an optional port. Theuser:password@form is deprecated for credentials and survives mainly in phishing and SSRF payloads. - path — hierarchical;
.and..segments are resolved during normalization. - query — by convention key=value pairs joined with ampersands, though RFC 3986 imposes no structure at all.
- fragment — never sent to the server. Client-side only, which is why SPA routes after
#do not appear in access logs.
Percent-Encoding Is Per-Component
The single most misunderstood fact: there is no one set of "characters that must be encoded." Each component has its own reserved set. A literal ? is a delimiter in the path but perfectly legal data inside the query; & is a data character in a path segment but a separator in a query. This is why one global encodeURIComponent over a full URL destroys it, and why the right practice is to encode each dynamic part as you assemble:
const url = new URL('https://api.example.com/search');
url.searchParams.set('q', 'a&b=c?');
url.toString(); // https://api.example.com/search?q=a%26b%3Dc%3F
Also remember the two spellings of space: %20 is the RFC 3986 encoding, while + means space only inside application/x-www-form-urlencoded data. Decoding + as space in a path is a bug.
URNs: Names Without Addresses
URNs (RFC 8141) live in registered namespaces and promise persistence, not resolvability:
urn:isbn:9780134685991
urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6
urn:ietf:rfc:3986
The ISBN names the book forever; finding a copy is a separate resolution step. In practice, URNs appear in XML namespaces, UUID serialization, and standards documents. The pattern to internalize: when you need an identifier that must never break even if hosting moves, a URN-style name (or any location-independent ID) beats a URL.
Two Specs, One Web
Here is the part most articles skip: modern browsers and JavaScript do not implement RFC 3986. They implement the WHATWG URL Standard, which is a living spec written to match what browsers actually do. Differences that bite:
- WHATWG parsing is more forgiving: it accepts backslashes as slashes in special schemes (
https:\\example.comparses), strips tab and newline characters, and lowercases the host. new URL(relative)throws without a base argument — the WHATWG parser has no concept of a standalone relative reference, while RFC 3986 dedicates a whole section to them.- Validators, proxies, and older server frameworks tend to follow RFC 3986, so a URL can be judged differently at each hop.
That disagreement is exploitable. URL parser confusion is a recurring SSRF root cause: a validator using one parser approves https://trusted.com\@evil.com/, then an HTTP client using another parser sends the request to evil.com. The defense is architectural: parse once, validate the parsed components (scheme allowlist, host allowlist), and pass the parsed object — never the raw string — to the code that makes the request.
Practical Rules
- Use
URLandURLSearchParams(or your language's equivalent) instead of string concatenation, both for building and for parsing. - Normalize before comparing: lowercase scheme and host, resolve dot segments, uppercase percent-encoding hex digits, drop default ports.
- Treat
/blogand/blog/as distinct resources; pick one canonical form and 301 the other. - Never decode a full URL in one pass and re-split it; split first, then decode each component.
Internationalized Identifiers
RFC 3986 only permits ASCII, so non-ASCII identifiers are layered on top. IRIs (RFC 3987) allow Unicode directly; converting an IRI to a URI percent-encodes the path and query and Punycode-encodes the host — bücher.example becomes xn--bcher-kva.example via IDNA. Two operational consequences follow. First, comparison must happen after normalization, because the same logical address has multiple encodings. Second, homograph attacks: a Cyrillic а is visually identical to a Latin a, letting аpple.com impersonate apple.com. Browsers mitigate by rendering mixed-script hosts in Punycode, and any system that displays user-supplied links should apply the same policy instead of trusting the glyphs.
Experiment with tricky component encodings in the URL Encoder at sdk.is/url-encoder.