1. Parsing a URL with url.Parse
When a beginner sees a URL, their hands reach for the good old strings.Split. It’s natural: “there’s a ?, so you can separate the query; there’s a /, so you can separate the path.” And for a while it even “works”. Then a #fragment shows up in the URL, then parameters with spaces, then %2F (that’s an encoded / inside a value), and your “perfect” Split starts cutting the wrong parts.
That’s exactly why Go has the net/url package: it forces you to treat a URL as structured data with parts: scheme, host, path, query, fragment. And instead of guessing “where the question mark is”, you read the field u.RawQuery. Programmers love predictability — net/url is exactly about that.
The most common scenario: we have a string and we want a *url.URL struct. For that there is url.Parse, and it returns two values: *url.URL and error. You can’t ignore the error: if the URL is malformed, it’s better to find out immediately than to debug later “why is the host empty”.
URL anatomy and the url.URL fields
Any URL, in a simplified form, can be represented like this:
scheme://host/path?query#fragment
The point is that these parts are independent. The query doesn’t “belong” to the path, the fragment isn’t a “continuation of the query”, and the host is not “the part of the string before the first slash” — it’s a separate component. In Go, this is reflected by the url.URL struct.
Let’s make a small “map legend” — what goes where:
| URL part | Example | Where it lives in Go (url.URL) | Important to remember |
|---|---|---|---|
| Scheme | |
|
Usually or |
| Host | |
|
May include a port |
| Path | |
|
Does not include |
| Query | |
|
Without the character |
| Fragment | |
|
Without the character |
A slightly “real-life” example: the URL https://golang.org/x/In%20Valid,X contains percent‑encoding (a space is turned into %20). If you try to “slice the string by hand” and “decode” something at the wrong time, you can create a mess quickly.
Example: parsing a full URL
package main
import (
"fmt"
"net/url"
)
func main() {
raw := "https://api.example.com/api/v1/tasks?done=true#top"
u, err := url.Parse(raw)
if err != nil {
fmt.Println("parse error:", err)
return
}
fmt.Println("scheme:", u.Scheme) // scheme: https
fmt.Println("host:", u.Host) // host: api.example.com
fmt.Println("path:", u.Path) // path: /api/v1/tasks
fmt.Println("rawQuery:", u.RawQuery) // rawQuery: done=true
fmt.Println("fragment:", u.Fragment) // fragment: top
}
Notice: RawQuery does not contain ?, and Fragment does not contain #. This is very useful: you don’t “trim service characters” by hand — Go has already done it for you.
Path, RawQuery, and Fragment are three different fields
A very typical confusion: someone thinks u.Path is “everything after the domain”. But no: “everything after the domain” is already a mix of Path + ?query + #fragment. In Go these things are separated into fields, and that’s really convenient.
Imagine you’re building an API, and you have a path /api/v1/tasks, while the query is optional output settings. If you accidentally mix them, you can get a bug like: “why doesn’t the router find a handler for /api/v1/tasks?done=true”. The router looks at the path, and the query is not part of the path.
To feel the difference hands-on, let’s print everything “in quotes” so that spaces and empty strings are visible:
package main
import (
"fmt"
"net/url"
)
func main() {
u, err := url.Parse("https://api.example.com/api/v1/tasks?done=true#top")
if err != nil {
fmt.Println("parse error:", err)
return
}
fmt.Printf("Path=%q RawQuery=%q Fragment=%q\n",
u.Path, u.RawQuery, u.Fragment,
)
// Path="/api/v1/tasks" RawQuery="done=true" Fragment="top"
}
And this is where many people have a little “click”: a URL is not a string — it’s a struct.
3. Absolute and relative URLs
Another typical situation: you parse a URL and suddenly see that u.Host == "". It seems like Parse “broke”. In reality it’s not a bug: you most likely parsed a relative URL.
A relative URL is when there is no https://api.example.com, only the path, for example /api/v1/tasks?limit=20. That’s a perfectly normal form (especially inside applications), and Go supports it.
package main
import (
"fmt"
"net/url"
)
func main() {
u, err := url.Parse("/api/v1/tasks?limit=20")
if err != nil {
fmt.Println("parse error:", err)
return
}
fmt.Printf("scheme=%q host=%q path=%q rawQuery=%q\n",
u.Scheme, u.Host, u.Path, u.RawQuery,
)
// scheme="" host="" path="/api/v1/tasks" rawQuery="limit=20"
}
Why does this matter? Because an “empty host” doesn’t always mean an error. Sometimes it means: “the URL is relative, and that’s intentional.”
4. Why strings.Split on ? is a delayed-action mine
Now for a mini horror story from real life (no blood, but with a faint smell of production).
If you do this: “find ?, everything after it is the query”, you forget that URLs can contain encoded characters (percent‑encoding). For example, a space can be %20, and there are also cases where a parameter value contains & but it’s encoded as %26. If you “cut the string” before decoding/after decoding in the wrong order, you can end up parsing it incorrectly.
A percent‑encoding example shows up even in test URLs like https://golang.org/x/In%20Valid,X. There, the space is part of the path but represented as %20. Your Split won’t break on %20, of course, but a “homegrown parser” typically accumulates hacks: then someone adds “let’s decode the entire string”, and now the space appears in the path, and your comparison/normalization logic starts behaving strangely.
net/url solves this problem conceptually: it first correctly parses the structure, and only then you carefully work with the parts.
5. Building a URL via the url.URL struct
So far we’ve talked about “how to parse”. Now the second important half: how to build a URL without manually concatenating strings.
When you concatenate like this:
base + "/api/v1/tasks" + "?done=true"
you start having questions:
what if base already ends with / — will you get //?
what if there are no parameters — will you get an extra ??
what if the value contains a space — how do you escape it?
Today we won’t go deep into building query parameters, but you should already get the core idea of assembling a URL through a struct.
Example: build a URL for the tasks resource
package main
import (
"fmt"
"net/url"
)
func main() {
u := url.URL{
Scheme: "https",
Host: "api.example.com",
Path: "/api/v1/tasks",
}
fmt.Println(u.String()) // https://api.example.com/api/v1/tasks
}
This looks almost too simple. But that’s the point: we’re not “drawing a string”, we’re “filling in fields”.
API base URL as url.URL
So the examples aren’t scattered, let’s agree: our sample application (a task tracker) has a base API address. For example:
- https://api.example.com (just an example domain),
- and the tasks resource lives at /api/v1/tasks.
A very handy practice is to store the “base” not as a string, but as a url.URL struct. That way you make fewer mistakes and it’s easier to “attach” final paths.
package main
import (
"fmt"
"net/url"
)
func tasksListURL(base url.URL) url.URL {
u := base // copy of the struct (important: not a pointer)
u.Path = "/api/v1/tasks"
return u
}
func main() {
base := url.URL{Scheme: "https", Host: "api.example.com"}
u := tasksListURL(base)
fmt.Println(u.String()) // https://api.example.com/api/v1/tasks
}
There’s a really nice detail hidden here: url.URL is a struct, and the assignment u := base makes a copy. That means you don’t “mess up” the base URL with accidental changes.
6. Diagnostics and the Parse → String model
In real development you most often deal not with “perfect” URLs, but with situations like: “why is my host empty?” or “why is the path suddenly missing a slash?” In those moments, simple diagnostics via fmt.Printf helps.
A debug printer for URLs
Let’s make a small debug printer that shows the fields explicitly:
package main
import (
"fmt"
"net/url"
)
func debugURL(raw string) {
u, err := url.Parse(raw)
if err != nil {
fmt.Println("parse error:", err)
return
}
fmt.Printf("raw=%q\n", raw)
fmt.Printf("scheme=%q host=%q path=%q rawQuery=%q fragment=%q\n",
u.Scheme, u.Host, u.Path, u.RawQuery, u.Fragment,
)
}
func main() {
debugURL("https://api.example.com/api/v1/tasks?done=true#top")
}
Printing with %q is like turning on “X-ray mode”: you see empty strings, special characters, and everything becomes much clearer.
Mini diagram: what Parse and String do
To reinforce the logic, it helps to see it not as magic, but as a simple pipeline: a string turns into a struct, and the struct turns back into a string.
flowchart TD
A["URL string
https://api.example.com/api/v1/tasks?done=true#top"] --> B["url.Parse(raw)"]
B -->|ok| C["*url.URL
Scheme/Host/Path/RawQuery/Fragment"]
B -->|err| D["error
handle and exit"]
C --> E["u.String()"]
E --> F["Built URL string"]
The key idea: you stop living “manually” in strings and start living in clear, well-defined parts.
7. Common mistakes when working with net/url
Mistake #1: ignoring err from url.Parse.
Beginners sometimes write u, _ := url.Parse(raw) and think: “well, it’s almost always fine.” And then one day a malformed URL arrives, u is partially filled, and the rest of the code works with garbage. If the URL matters for your logic, a parse error should be handled explicitly, even if for now it’s just fmt.Println and return.
Mistake #2: mixing up Path and RawQuery.
A very common bug: you compare the path to an expected value, but you take “everything after the domain”, including ?limit=20. In Go, u.Path is the path without the query, and u.RawQuery is the query without the ?. If you keep it in your head as “two separate compartments”, there’s much less confusion.
Mistake #3: thinking Fragment is part of the query.
A fragment (#top) does not participate in query parameters. It lives separately, and typically isn’t sent to the server the way you might expect (in browser scenarios). Even if you’re not writing a browser right now, it’s useful to remember: u.Fragment is a separate field; don’t mix it with u.RawQuery.
Mistake #4: “concatenating” a URL with + and forgetting the leading / in the path.
One of the most annoying little gotchas: you wrote Path: "api/v1/tasks" instead of "/api/v1/tasks", got a weird URL, and then you debug “why is the server returning 404”. When building via url.URL, discipline is easier: you see Path as a separate field and you notice sooner that the slash is missing.
Mistake #5: being scared of an empty Host after Parse.
If you parsed /api/v1/tasks?limit=20, then Scheme and Host will be empty, and that’s normal: you parsed a relative URL. The “mistake” here is more psychological: it feels like “the data disappeared”, when in reality it’s just a different URL form.
GO TO FULL VERSION