# cascadia: CSS selectors for Go HTML trees

> cascadia is a BSD 2-Clause licensed Go library that implements CSS selectors for the parse trees produced by the standard html package, with pseudo classes, specificity calculation and a selector serialiser, and it depends on nothing but golang.org/x/net. The judgement is that the scope is deliberately narrow and the test suite unusually thorough for a package this small, while the release tagging stopped eight years ago even though the code is still being worked on.

**andybalholm/cascadia** — CSS selector library in Go

- Repository: https://github.com/andybalholm/cascadia
- Stars: 760 · Forks: 75
- Language: Go
- License: BSD-2-Clause
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/andybalholm-cascadia

## One selector engine over the standard Go HTML tree

Cascadia implements CSS selectors for use with the parse trees produced by the html package. That single sentence is the entire scope of the project, and the dependency list confirms it: the module is github.com/andybalholm/cascadia, it requires Go 1.25.0, and its only requirement is golang.org/x/net at v0.55.0.

That is the whole reason to use it. The Go standard ecosystem already has a well tested HTML parser in golang.org/x/net/html, and what it does not have is a selector language. Cascadia fills exactly that gap and nothing else, so it composes with the parser rather than replacing it.

The consequence is that there are no surprises in the dependency tree. A service that already pulls in golang.org/x/net for its HTTP and HTML handling adds one module and one import to gain the ability to write div.card.pricing-card-title instead of hand walking parent and child pointers.

The API reference is on godoc, and the readme also points at a separate command line tool, the cascadia project by suntong, described as a thin wrapper around this package, for testing CSS selectors without writing Go code. That tool is worth knowing about when evaluating whether the selector syntax you want is supported, because it turns the question into a shell command rather than a Go program.

## Parse the selector once, then apply it

The API has two steps, and separating them is what makes repeated queries cheap.

cascadia.Parse takes a selector string and returns a parsed selector, plus an error. The parsed value can then be applied to a node with cascadia.Query, which returns the first match, or with cascadia.QueryAll, which returns every match. The example in the readme shows the pattern in its simplest form:

```go
func Query(n *html.Node, query string) *html.Node {
	sel, err := cascadia.Parse(query)
	if err != nil {
		return &html.Node{}
	}
	return cascadia.Query(n, sel)
}
```

The error handling in the example returns an empty node and an empty slice, which is a choice rather than a requirement, and the readme is not prescriptive about it. It is a reasonable default for a helper whose callers often do not care whether a selector was valid.

The design point is that the parse step is separate from the query step. A document with three hundred pricing cards and a loop over them should parse the selector once, outside the loop, and then apply it three hundred times, and the API allows exactly that. It also means a program can validate a selector once at start-up and then treat a parse failure as a configuration error rather than something that happens in a hot loop.

## The example walks a pricing table with structural selectors

The readme example is a pricing table, and it is a good demonstration of what the library covers.

The document is a set of card divs, each with a class list, an h4 for the plan name, an h1 for the price, and a list of three items. The selector for a card is a compound class selector, div.card.mb-4.box-shadow, which is the ordinary CSS way of writing it and requires no library specific syntax.

Inside each card, the fields are pulled with a mix of type, class and structural selectors:

```go
	for i, p := range QueryAll(doc, "div.card.mb-4.box-shadow") {
		planName := Query(p, "h4").FirstChild.Data
		price := Query(p, ".pricing-card-title").FirstChild.Data
		usersIncluded := Query(p, "li:first-child").FirstChild.Data
```

The structural selectors are the interesting part. li:first-child picks the first list item, li:nth-child(2) picks the second by position rather than by class, and li:last-child a picks the anchor inside the final item. These are the pseudo classes that separate a real selector engine from a simple tag and class matcher, and they are the ones that browsers implement with subtle variations.

The example also handles a missing attribute. The last plan in the sample has an anchor with no href, and a small helper walks the node's attributes to find one by key, falling back to a string passed in by the caller. That is a reminder that a selector engine does not tell you whether an attribute is present, only which node matches.

## The source is split into the parts of the specification

The file listing shows a package organised along the lines of the CSS specification rather than around a single entry point.

There is parser.go, which turns selector text into the internal representation, and selector.go, which holds the selector types and the matching logic. pseudo_classes.go is where the structural and state pseudo classes live, which is the part that decides what a first-child or an nth-child means in this implementation rather than in a browser.

Then there are two files with no direct equivalent in a typical selector package. serialize.go writes a selector back out as text, and specificity.go computes the specificity of a selector.

Specificity is the one that surprises people. CSS uses specificity to decide which declaration wins when several rules apply to the same element, so a selector engine that can only match cannot answer a question a stylesheet author would ask. Having the calculation in the package means a tool that needs to reason about stylesheets, rather than merely extract elements, can do so with the same parsed selectors.

Serialisation matters for a similar reason. A selector that can be printed back out is a selector that can be stored, compared, logged and shown in an error message, and a selector engine that can only consume text is much harder to embed in a tool with a user interface.

The files are small and the package has no dependencies beyond the html parser, so each of these concerns is a few hundred lines rather than a subsystem.

## The tests reach for the W3C and for fuzzing

The test files are more interesting than the source, because they show how the author convinced himself the implementation matches the specification.

There is w3_test.go, which is the strongest signal in the listing: it points at the W3C test suite for CSS selectors, the same kind of conformance corpus that other projects use. A selector engine is exactly the kind of component where conformance to a written specification matters, because the behaviour is defined by documents rather than by the code.

There is also a fuzz directory, which means the parser is fuzzed, and that is the right place to attack a parser that turns user supplied strings into a tree, which is what a selector string is when it comes from a configuration file or a request parameter.

The remaining test files cover the parts separately: parser_test.go, selector_test.go, serialize_test.go and specificity_test.go for each file in the package, lang_test.go for the language level behaviour, null_escape_test.go for the escaping edge cases in a selector string, and benchmark_test.go for performance. There is also a test_resources directory for the fixtures those tests need.

For a package with no release history in eight years, having both a conformance suite and a fuzz target is a good sign about how it is maintained, even though the release tagging tells you nothing.

## One tag from 2018, and a repository that is still being changed

The release history is one line. Version v1.0.0, titled Start using version numbers, was published on 2018-02-20, and there is nothing after it.

The note in the release title is the explanation. The project used to version by date or by nothing at all, and that release marks the switch to semantic version numbers, which is why the number is 1.0.0 rather than something like 2018.02. The absence of later tags means either that the maintainer decided to tag only rarely, or that releases stopped being part of the workflow.

The repository, though, is not abandoned. The last push was on 2026-10-19, and the module requires Go 1.25.0 and golang.org/x/net at v0.55.0, which are current versions. The code is being kept in step with the ecosystem it depends on, which is the maintenance that matters for a library like this one.

The continuous integration badge is the outdated part. It points at a Travis CI build on travis-ci.org, which is a service that has been effectively retired for open source projects, so the badge tells you that continuous integration was configured, not that it is running.

The licence is BSD 2-Clause, which is the permissive end and the usual choice for a small Go library that other people embed in their own services.

## Conclusion

cascadia fits a Go project that already parses HTML with golang.org/x/net/html and wants to select nodes with the selectors web developers already know, because it is a single dependency with no transitive baggage and an API of parse, query and query all. It is the wrong choice if you need a full HTML toolkit, since it does no parsing of its own and offers no text extraction, and it is the wrong choice if you need versioned releases to pin, because the only tag in the repository is v1.0.0 from 2018-02-20 and the project has been shipping from the default branch since. Before you adopt it, pin the commit rather than the tag, and read the specificity and pseudo class sources before you rely on the result of a complex selector, because those are the two places where a Go implementation and a browser can disagree.

## FAQ

### What does the cascadia package do?

It implements CSS selectors for use with the parse trees produced by the html package from golang.org/x/net. The API is a parse step that turns a selector string into a selector, and a query step that applies a parsed selector to a node, returning either the first match or all matches.

### Which selectors does cascadia support?

The readme example uses compound class selectors such as div.card.mb-4.box-shadow, type and class selectors, and structural pseudo classes including li:first-child, li:nth-child(2) and li:last-child a. The package also has a specificity implementation and a selector serialiser.

### Can I test CSS selectors without writing Go?

Yes. The readme points at a separate command line tool, the cascadia project by suntong, which is described as a thin wrapper around this package and exists to test selectors without writing Go code.

### How do I install cascadia?

It is a Go module, github.com/andybalholm/cascadia, requiring Go 1.25.0 and depending only on golang.org/x/net at v0.55.0. The licence is BSD 2-Clause, and the last push to the repository was on 2026-10-19.

## Sources

- [andybalholm/cascadia on GitHub](https://github.com/andybalholm/cascadia)
- [Issues](https://github.com/andybalholm/cascadia/issues)
- [License: BSD-2-Clause](https://github.com/andybalholm/cascadia/blob/master/LICENSE)
- [README](https://github.com/andybalholm/cascadia/blob/master/README.md)
- [Releases](https://github.com/andybalholm/cascadia/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/andybalholm-cascadia
