Skip to content
Select theme

How to parse a URL

Short answer: Url::parse takes a &str and returns Result<Url, url::ParseError>. The Url it hands back carries the pieces as accessors — scheme(), host_str(), path(), query_pairs() — and query_pairs() is already percent-decoded.

One crate, no runtime and no async:

Cargo.toml
[dependencies]
url = "2.5.8"

cargo new urldemo gives you the project; that line goes under the [dependencies] it already wrote. 2.5.8 is what the resolver picks for url = "2" today, and the API below is the 2.x line.

src/main.rs
use url::Url;
fn main() {
let url = Url::parse("https://www.rust-lang.org/learn?edition=2024#getting-started").unwrap();
println!("as_str: {}", url.as_str());
println!("scheme: {}", url.scheme());
println!("host: {:?}", url.host_str());
println!("port: {:?}", url.port());
println!("port_or_known_default: {:?}", url.port_or_known_default());
println!("path: {}", url.path());
println!("query: {:?}", url.query());
println!("fragment: {:?}", url.fragment());
println!();
let mail = Url::parse("mailto:[email protected]").unwrap();
println!("as_str: {}", mail.as_str());
println!("scheme: {}", mail.scheme());
println!("host: {:?}", mail.host_str());
println!("path: {}", mail.path());
}

Run it with cargo run (cargo’s own Compiling and Finished lines are not shown here):

as_str: https://www.rust-lang.org/learn?edition=2024#getting-started
scheme: https
host: Some("www.rust-lang.org")
port: None
port_or_known_default: Some(443)
path: /learn
query: Some("edition=2024")
fragment: Some("getting-started")
as_str: mailto:[email protected]
scheme: mailto
host: None

Three details in that output are worth a second look:

  • port() is None even though the URL is https. It reports the port only when the URL states one explicitly — give the same host https://example.com:8443/x and port() comes back Some(8443). port_or_known_default() fills in 443 for the https scheme, which is usually the number you want.
  • as_str() is the normalised form, and normalising adds a path. Parse https://example.com with no path at all and the result serialises as https://example.com/, with path() reporting /.
  • A mailto: URL has no host. For a scheme with no authority component there is nothing to hand back, host_str() is None, and the address sits in path(). Code that unwraps host_str() meets its first mailto: link and panics.
src/main.rs
use url::Url;
fn main() {
let url = Url::parse("https://example.com/search?q=hello%20world&lang=en-US&tag=rust%2Blang&plus=a+b").unwrap();
for (key, value) in url.query_pairs() {
println!("{key} = {value}");
}
}
q = hello world
lang = en-US
tag = rust+lang
plus = a b

query_pairs() returns an iterator of (Cow<str>, Cow<str>), decoded on the way out: %20 is a space and %2B is a literal +. A literal + in the input goes the other way and decodes to a space, which is the application/x-www-form-urlencoded rule and the subject of How to parse a query string. A value that must carry a real + needs it encoded as %2B, as the tag pair shows.

The other half of the job is join, which resolves a relative reference against a base URL:

src/main.rs
use url::Url;
fn main() {
let base = Url::parse("https://example.com/docs/book/ch04-00.html").unwrap();
for relative in ["ch04-01.html", "../ch05-00.html", "/index.html", "https://other.example/x"] {
println!("{relative} -> {}", base.join(relative).unwrap());
}
}
ch04-01.html -> https://example.com/docs/book/ch04-01.html
../ch05-00.html -> https://example.com/docs/ch05-00.html
/index.html -> https://example.com/index.html
https://other.example/x -> https://other.example/x

.. climbs out of the last path segment, a leading / replaces the path entirely, and an absolute URL comes back unchanged. This is the call a link scraper needs; the same job done by string concatenation is where the bugs live.

Url::parse returns a Result, and the failure is common enough to plan for:

src/main.rs
use url::Url;
fn main() {
for candidate in ["example.com", "/just/a/path", "https://"] {
match Url::parse(candidate) {
Ok(url) => println!("{candidate:?} parsed as {url}"),
Err(e) => println!("{candidate:?} failed: {e:?}"),
}
}
}
"example.com" failed: RelativeUrlWithoutBase
"/just/a/path" failed: RelativeUrlWithoutBase
"https://" failed: EmptyHost

example.com is not a URL: there is no scheme, so there is nothing to parse, and join is the only way to give it a base. https:// names the scheme and then no host. The blocks above unwrap their parses because the strings are literals known to be valid; a URL read from a file or an argument gets its Result handled instead.

The next article in this chapter is about the TLS certificate error, and why turning verification off is the wrong fix.