How Does PostCSS Work?

PostCSS turns a stylesheet into a tree, lets plugins change that tree, and turns it back into CSS. Here is each stage, with small code examples.

Illustrated cover: How Does PostCSS Work?
Illustration by the editorial team.

The pipeline in one picture

Every PostCSS run follows the same path:

  1. Tokenize the CSS text into small pieces (words, brackets, strings, comments).
  2. Parse those tokens into an Abstract Syntax Tree (AST).
  3. Run plugins, which read and modify the AST.
  4. Stringify the AST back into CSS text, optionally with a source map.

PostCSS itself only owns steps 1, 2 and 4. Step 3 is entirely up to the plugins you install. That is why the core package is small: about 216 KB unpacked with just three dependencies.

Step 1: the tokenizer

The tokenizer reads the raw string character by character and groups it into tokens such as word, string, brackets, comment, { and }. It also tracks line and column numbers for every token, which is how error messages and source maps can point to exact positions later.

The tokenizer is also where edge cases live. The 2023 security fix (CVE-2023-44270) was a change to how carriage-return characters inside comments are tokenized.

Step 2: the parser and the AST

The parser assembles tokens into nodes. Each node has a type:

PostCSS node types.
NodeRepresentsExample
RootThe whole fileeverything in style.css
AtRuleAn at-rule@media (min-width: 600px)
RuleA selector with a block.card { … }
DeclarationA property and valuecolor: red
CommentA comment/* note */
DocumentA container for several rootsused by custom syntaxes such as HTML or Vue files

You can see the tree yourself:

parse.js
const postcss = require('postcss')
const root = postcss.parse('.card { color: red }')

console.log(root.first.selector)      // .card
console.log(root.first.first.prop)    // color
console.log(root.first.first.value)   // red

Nodes also keep raws, the original whitespace and formatting. That lets PostCSS print untouched parts of the file exactly as you wrote them, which is important for linters and formatters.

Step 3: plugins

A plugin is an object with a postcssPlugin name and one or more listeners. The modern API (PostCSS 8) uses a visitor pattern: you subscribe to node types and PostCSS calls you as it walks the tree.

plugin.js
module.exports = (opts = {}) => {
  return {
    postcssPlugin: 'no-red',
    Declaration (decl, { result }) {
      if (decl.value === 'red') {
        decl.warn(result, 'Use a color token instead of red')
      }
    }
  }
}
module.exports.postcss = true

Useful listeners include:

  • Once and OnceExit: run one time on the Root, before and after other listeners.
  • Rule, AtRule, Declaration, Comment: run for each node of that type.
  • Keyed forms such as AtRule: { media: … } or Declaration: { color: … } to target specific names.

Because all plugins share one walk of the tree, PostCSS can revisit nodes that another plugin changed. That is what makes it possible to chain many plugins without each re-parsing the file.

Plugin order

Plugins run in the order you list them in your config. A nesting plugin must run before Autoprefixer so that nested rules are flattened first; a minifier such as cssnano should be last.

Step 4: stringify and source maps

When plugins finish, the stringifier walks the tree and writes CSS. Untouched nodes reuse their saved raws; new nodes get sensible default formatting. If you pass the map option, PostCSS builds a source map that links output positions to the original file, and it can merge an existing map from an earlier step such as Sass.

process.js
const postcss = require('postcss')
const autoprefixer = require('autoprefixer')

postcss([autoprefixer])
  .process(css, { from: 'src/app.css', to: 'dist/app.css', map: { inline: false } })
  .then(result => {
    console.log(result.css)            // final CSS
    console.log(result.map.toString()) // source map
    result.warnings().forEach(w => console.warn(w.toString()))
  })

Lazy results

process() returns a LazyResult. Plugins do not run until you read result.css, call .then() or similar. If you call process() with no plugins at all, PostCSS can skip work and simply return the input; internally that path is called NoWorkResult.

How build tools run PostCSS

You rarely call the API by hand. Runners do it for you:

  • Vite detects a PostCSS config and applies it to imported CSS.
  • webpack uses postcss-loader in the CSS rule chain.
  • postcss-cli runs from npm scripts: postcss src -d dist.
  • gulp-postcss and other integrations wrap the same API.

All of them read the same PostCSS config file.

Custom syntaxes

The parser and stringifier are swappable. With postcss-scss or postcss-less as the syntax option, PostCSS can parse SCSS or Less files without compiling them, which is how Stylelint lints those languages.

Summary

  • PostCSS = tokenizer + parser + plugin runner + stringifier.
  • The AST uses Root, AtRule, Rule, Declaration, Comment and Document nodes.
  • Plugins subscribe to node types and edit the tree; order matters.
  • Source maps and preserved raws keep output traceable and tidy.

Get PostCSS 8.5.32

Free, MIT licensed, downloaded from the official GitHub repository.

Download PostCSS