All articles
Validators
29 min readBy DevUtilX Team

Sass and SCSS Validation and Linting Best Practices

Validate Sass and SCSS in three layers: Dart Sass compile errors, valid compiled CSS, and Stylelint rules, with real error output and CI setup.

Sass and SCSS Validation and Linting Best Practices

A stylesheet that looks fine in your editor can still fail the build at 5 p.m. on a Friday. With plain CSS the browser forgives almost everything: a typo in a property name is skipped, a missing semicolon swallows one declaration, and the page keeps rendering. Sass and SCSS are not that forgiving. They are programming languages that compile to CSS, so a single misplaced brace, an undefined variable or an old-style division can stop the whole compile. And even when the compile succeeds, the CSS it produces may still be wrong.

This guide is for front-end developers, design-system maintainers and anyone who inherits a styles/ folder full of partials. You will learn the two layers of Sass validity, how to read real Dart Sass error messages, how to catch the mistakes that compile cleanly but ship broken CSS, how to set up Stylelint for SCSS, and how to wire everything into your editor, pre-commit hook and CI pipeline. Every error message and every command output in this article was produced by running the tools on the sample files shown.

What Sass and SCSS are, and why validation is different

Sass (Syntactically Awesome Style Sheets) is a CSS preprocessor. You write variables, nesting, mixins, functions and modules, and a compiler turns that source into ordinary CSS. It first appeared in the mid-2000s as part of the Ruby ecosystem, and it originally used an indentation-based syntax with no braces or semicolons. A later syntax called SCSS (Sassy CSS) made every valid CSS file a valid SCSS file by keeping braces and semicolons, and it became the dominant way to write Sass.

Over the years the implementations changed. The original Ruby Sass and the C-based LibSass have both been retired in favour of Dart Sass, which is the reference implementation and the one the official Sass documentation describes. When someone says "install Sass" today, they almost always mean the sass package on npm, which is Dart Sass compiled to JavaScript.

Two syntaxes share one language:

Aspect SCSS (.scss) Indented Sass (.sass)
Blocks Curly braces Indentation
Statement end Semicolon Line break
Valid CSS is valid input Yes No
Typical error type Missing } or ; Wrong indentation, mixed tabs and spaces
Popularity Much more common Niche but supported

Because of this, validating a Sass file is a different job from validating CSS. A CSS validator checks a fixed grammar against a fixed list of properties. A Sass validator has to parse a full language with variables, control flow and imports, resolve every module the file depends on, and evaluate the result. That leads to three distinct questions:

  1. Does the source parse and compile? This is the Dart Sass layer. It catches syntax errors, undefined names and module problems.
  2. Is the compiled CSS valid CSS? Sass passes through properties and values it does not know. A typo can compile cleanly and still produce CSS the browser ignores.
  3. Does the code follow your team's rules? This is linting: naming, nesting depth, ordering, duplicate selectors and architecture.

A good workflow covers all three. The rest of this article takes them in order. If you only want a quick check of a snippet, paste it into the SCSS Validator or, for the indented syntax, the Sass Validator.

Layer one: how the compiler validates your source

Dart Sass processes a stylesheet in stages. It parses the text into a tree, loads every module referenced by @use, @forward and @import, evaluates variables, functions and control flow, and finally serialises the result as CSS. An error can be raised at any stage, and knowing which stage you are in tells you what kind of mistake to look for.

  • Parse errors happen first. The text does not match the grammar: an unbalanced brace, a missing semicolon, an unclosed string, a bad interpolation.
  • Loading errors come next: a file that cannot be found, or two files that load each other in a loop.
  • Evaluation errors come last: an undefined variable, a mixin called with the wrong arguments, an @extend that finds no target.

Every error includes a message, a snippet with a caret under the problem, and a location in the form file line:column. The snippet is the part you should read first.

Syntax errors in SCSS

The most common SCSS mistake is an unbalanced brace. Here is a file where the closing brace for .card is missing:

.card {
  color: red;
  .title {
    font-size: 2rem;
}

Running sass missing-brace.scss produced this output:

Error: expected end of rule.
  ╷
5 │ }
  │  ^
  ╵
  missing-brace.scss 5:2  root stylesheet

Notice that the error points to the end of the file, line 5, not to the line where you forgot the brace. The parser only realises something is wrong when it runs out of input while still inside a rule. When you see "expected end of rule" or "expected }" at the very end of a file, scan upward for the block that was never closed. Consistent indentation makes that search easy, which is one reason formatting matters.

A missing semicolon is the second classic:

.card {
  color: red
  padding: 1rem;
}
Error: expected ";".
  ╷
3 │   padding: 1rem;
  │          ^
  ╵
  missing-semi.scss 3:10  root stylesheet

Here the caret sits on the line after the real problem. The parser read color: red padding: 1rem as one value, then hit the colon and realised a semicolon should have appeared earlier. The rule of thumb is to look at the line before the one the caret marks.

Syntax errors in map literals and interpolation

Maps are comma-separated lists of key-value pairs in parentheses, and a forgotten comma behaves like a forgotten semicolon:

$colors: (
  primary: #06c
  accent: #f60
);
Error: expected ")".
  ╷
3 │   accent: #f60
  │         ^
  ╵
  map.scss 3:9  root stylesheet

The fix is a comma after #06c. A trailing comma after the last pair is allowed and is a good habit, because it keeps diffs small when you add an entry.

Interpolation, written #{...}, lets you drop a Sass expression into a selector, property name or string. An unclosed interpolation is reported like this:

$name: card;
.#{$name {
  color: red;
}
Error: expected "}".
  ╷
2 │ .#{$name {
  │          ^
  ╵
  interp.scss 2:10  root stylesheet

The corrected form is .#{$name} { ... }, which compiles to .card { color: red; }. Interpolation is also the right tool when you need to keep a slash or a variable inside a CSS function such as calc(), which we revisit in the division section below.

Syntax errors in the indented Sass syntax

The indented syntax replaces braces with whitespace, so whitespace is where it fails. Dart Sass requires consistent indentation within a block, and it does not allow tabs and spaces to be mixed. Here is a file where one line was indented with a tab:

.card
  color: red
	padding: 1rem
Error: Expected spaces, was tabs.
  ╷
3 │     padding: 1rem
  │ ^^^^
  ╵
  tabs.sass 3:1  root stylesheet

The snippet displays the tab as spaces, which is exactly why this bug is hard to see. Configure your editor to insert spaces and to show whitespace characters. A line that is indented deeper than its siblings gives a different message:

.card
    color: red
  padding: 1rem
Error: Inconsistent indentation, expected 4 spaces.
  ╷
3 │   padding: 1rem
  │ ^^
  ╵
  indent.sass 3:1  root stylesheet

The first line of the block set the indentation to four spaces, so the third line with two spaces is inconsistent. Pick one width, usually two spaces, and keep it.

The most dangerous indented-syntax mistake is the one that does not error at all. I indented one line a little too far:

.card
  color: red
   padding: 1rem

This compiled without a complaint, and the output was:

.card {
  color: red;
  color-padding: 1rem;
}

Sass treated the extra-indented line as a nested property, a feature where color: followed by indented children produces color-child properties. The result is a property called color-padding that no browser understands, and padding silently disappears. Syntax validation alone cannot catch this, because the file is valid. You need to look at the compiled output or run a CSS-level check, which is layer two.

Undefined names: variables, mixins, functions and extends

Evaluation errors are about names that do not exist at the point of use. They are the most frequent errors in large codebases, usually after a rename or a refactor that moved a file.

Undefined variables

.btn {
  color: $brand-color;
}
Error: Undefined variable.
  ╷
2 │   color: $brand-color;
  │          ^^^^^^^^^^^^
  ╵
  undef-var.scss 2:10  root stylesheet

Check three things: is the name spelled exactly the same (Sass treats - and _ in names as identical, but nothing else), is the variable defined before it is used, and is it visible from this file? Under the modern module system a variable defined in another file is only visible if you load that file with @use and refer to it through its namespace.

Undefined mixins

.btn {
  @include rounded(4px);
}
Error: Undefined mixin.
  ╷
2 │   @include rounded(4px);
  │   ^^^^^^^^^^^^^^^^^^^^^
  ╵
  undef-mixin.scss 2:3  root stylesheet

The message is short, but the cause is nearly always a missing @use or a typo in the mixin name. If the mixin lives in a module loaded as @use "mixins", you must write @include mixins.rounded(4px).

A missing function is not always "undefined"

A call to a function Sass does not know is not an error. Sass treats it as a plain CSS function and emits it unchanged. That is how rgb(), var() and calc() work, and it is also why a typo can go unnoticed. When I wrote width: rem(16px); with no rem function defined, Dart Sass complained about the arguments instead:

Error: 2 arguments required, but only 1 was passed.
  ╷
2 │   width: rem(16px);
  │          ^^^^^^^^^
  ╵
  undef-fn.scss 2:10  root stylesheet

In this case rem() is a built-in CSS math function that takes two arguments, so the message is about arity rather than a missing name. Be careful with user-defined function names that collide with CSS function names. If you meant a helper that converts pixels to rem, give it a distinct name like to-rem.

The unmatched @extend

@extend makes one selector inherit the styles of another. If the target does not exist, Sass refuses to guess:

.btn {
  @extend .missing;
}
Error: The target selector was not found.
Use "@extend .missing !optional" to avoid this error.
  ╷
2 │   @extend .missing;
  │   ^^^^^^^^^^^^^^^^
  ╵
  extend.scss 2:3  root stylesheet

Most of the time the right fix is to define the target, or to switch to a mixin or a placeholder selector (%name) that is guaranteed to exist. Use !optional only when the target is genuinely conditional, because it also hides typos.

Modules: @use, @forward and @import

Sass replaced its global, file-concatenation @import with a module system built on @use and @forward. Each module has its own namespace, so variables from one file no longer leak into another. This is a big improvement for validation because undefined names become precise errors instead of accidental successes.

Using a module without its namespace

Suppose _vars.scss contains $brand: #06c; and another file loads it:

@use "vars";
.a { color: $brand; }
Error: Undefined variable.
  ╷
2 │ .a { color: $brand; }
  │             ^^^^^^
  ╵
  use-bad.scss 2:13  root stylesheet

With @use, the variable lives under the module's namespace, so the correct reference is vars.$brand. You can also pick a different namespace with @use "vars" as v; or remove it with as *, though removing it brings back the collision risks the module system was designed to avoid.

@use must come first

.a { color: red; }
@use "vars";
Error: @use rules must be written before any other rules.
  ╷
2 │ @use "vars";
  │ ^^^^^^^^^^^
  ╵
  use-late.scss 2:1  root stylesheet

Only @forward, variable declarations and a few configuration statements may appear before a @use. Move all @use lines to the top of the file.

Circular dependencies

If c1.scss loads c2.scss and c2.scss loads c1.scss, Sass stops with a helpful trace:

Error: Module loop: this module is already being loaded.
  ╷
1 │ @use "c1";
  │ ^^^^^^^^^
  ╵
  c2.scss 1:1  @use
  c1.scss 1:1  root stylesheet

The trace at the bottom reads like a stack trace: c1 loaded c2, which tried to load c1 again. Break the loop by extracting the shared pieces (usually variables or functions) into a third file that both can load.

Files that cannot be found

Error: Can't find stylesheet to import.
  ╷
1 │ @use "nothere";
  │ ^^^^^^^^^^^^^^
  ╵
  nofile.scss 1:1  root stylesheet

Despite the word "import" in the message, this applies to @use as well. Check the path, remember that partials start with an underscore on disk but not in the rule, and look at whether the file is reachable from the working directory or from a --load-path. A missing --load-path=node_modules is the usual reason a third-party import fails only in CI.

The @import deprecation

The old @import rule still works in Dart Sass, but it is deprecated. Compiling this file:

@import "vars";
.a { color: $brand; }

produced the correct CSS plus this warning:

DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.

More info and automated migrator: https://sass-lang.com/d/import

Treat a deprecation warning as a validation failure that has not been scheduled yet. Everything still works today, but the warning tells you exactly which feature will break. Migrating means replacing @import with @use (or @forward for index files), adding namespaces and moving any global state into explicit configuration. The warning mentions an automated migrator, and it is worth trying on a copy of the code first.

Division: / versus math.div

In older Sass, the slash between two numbers meant division in some contexts and a plain separator (as in font: 12px/1.5) in others. Dart Sass made this unambiguous by deprecating / as division and introducing math.div in the sass:math module. This code:

.a { width: (100px / 3); }

compiled, but only after printing:

DEPRECATION WARNING [slash-div]: Using / for division outside of calc() is deprecated and will be removed in Dart Sass 2.0.0.

Recommendation: math.div(100px, 3) or calc(100px / 3)

The corrected version loads the math module first:

@use "sass:math";
.a { width: math.div(100px, 3); }

which produced width: 33.3333333333px;. When the division involves values that are only known at run time in the browser, calc() is the better answer. To pass a Sass variable into calc() you need interpolation. In one test, with $a: 10px, the expression calc(#{$a} / 2) produced calc(10px / 2), while math.div($a, 2) produced 5px. Multiplication with * needs no change.

Layer two: is the compiled CSS valid?

Dart Sass checks the Sass language. It does not check whether the property and value pairs you emit are real CSS. Here is a file with three problems:

.a {
  colour: red;
  display: flexx;
  margin: 1rem 2rem 3rem 4rem 5rem;
}

Dart Sass compiled it with exit code 0 and copied all three declarations to the output. A browser would ignore every one of them. The compile succeeded because Sass treats unknown properties as custom output, which is exactly what you want when a new CSS feature ships before the compiler learns about it.

This is why the second layer exists. You can catch these mistakes in three ways:

  • A CSS linter. Stylelint's property-no-unknown rule flagged colour when I ran it on this file, producing Unknown property "colour". It did not flag the invalid value flexx, because value validation for every property is a harder problem; the standard config focuses on names and syntax.
  • A CSS validator on compiled output. Compile first, then validate the CSS, for example with the W3C CSS Validator or a tool that checks values against the specifications. Use whichever validator your team prefers.
  • Visual and end-to-end tests. A browser is the final judge. If a style matters, a screenshot test proves that it applied.

The earlier nested-property accident (color-padding) belongs in the same category: valid Sass, invalid intent. The practical lesson is to review the compiled CSS for anything surprising, at least when you change the structure of a file.

Layer three: linting Sass with Stylelint

Linting answers the question "is this code consistent and maintainable?" rather than "does it work?". For Sass the standard tool is Stylelint, a CSS linter with a plugin architecture that has strong SCSS support.

Why SCSS needs special configuration

Stylelint's core rules assume CSS. SCSS adds syntax that CSS does not have: // comments, $variables, @mixin, @include, nested rules with &, placeholders and maps. A plain CSS parser would choke on those. Stylelint solves this by letting you choose a custom syntax. For SCSS, the postcss-scss parser understands the extra syntax, and the shareable config stylelint-config-standard-scss sets it up for you, along with a sensible rule set and the SCSS-specific plugin rules.

One rule trap deserves a mention. Core at-rule-no-unknown flags every Sass at-rule such as @mixin or @include as unknown. The SCSS config turns that rule off and enables the plugin version, scss/at-rule-no-unknown, which knows the Sass at-rules. If you copy a CSS configuration into an SCSS project, you will see a wall of false positives from this rule.

A sample configuration

Install the tools as dev dependencies:

npm install --save-dev sass stylelint stylelint-config-standard-scss prettier

Then create .stylelintrc.json:

{
  "extends": "stylelint-config-standard-scss",
  "rules": {
    "max-nesting-depth": 3,
    "selector-class-pattern": [
      "^[a-z][a-z0-9]*(-[a-z0-9]+)*(__[a-z0-9]+(-[a-z0-9]+)*)?(--[a-z0-9]+(-[a-z0-9]+)*)?$",
      { "message": "Use BEM kebab-case class names" }
    ],
    "no-descending-specificity": true,
    "scss/at-rule-no-unknown": true,
    "at-rule-no-unknown": null
  }
}

The extends line brings in the whole standard rule set. The rules below it are overrides or additions that match typical team standards. They deserve a closer look:

Rule What it enforces Why it helps
max-nesting-depth Limits how deeply rules can nest Deep nesting produces long, high-specificity selectors that are hard to override
selector-class-pattern Class names must match a regular expression Keeps naming conventions such as BEM consistent
no-descending-specificity A less specific selector must not appear after a more specific one targeting the same element Prevents rules that are silently overridden
scss/at-rule-no-unknown Reports at-rules Sass does not define Intended to catch unknown at-rules; verify on your code
declaration-block-no-duplicate-properties No repeated property in one block (on in the standard config) Duplicates are usually mistakes or leftovers
scss/dollar-variable-pattern Variable names must match a pattern (on in the standard config) Consistent naming across the codebase

Real output on a messy file

I wrote a deliberately poor file, bad.scss:

@import "vars";
$Brand-Color: #06c;
.Card__Title { color: $Brand-Color; }
.card {
  .a { .b { .c { .d { color: red; } } } }
  color: red;
  color: blue;
}
.card a { color: blue; }
a { color: red; }
.card {
  margin: 0
}
@foo;

Running npx stylelint bad.scss with the configuration above printed:

bad.scss
   2:1   ×  Expected an empty line before $-variable                            scss/dollar-variable-empty-line-before
   2:1   ×  Expected variable name "Brand-Color" to be kebab-case               scss/dollar-variable-pattern
   3:1   ×  Use BEM kebab-case class names                                      selector-class-pattern
   4:1   ×  Expected empty line before rule                                     rule-empty-line-before
   5:18  ×  Too deep nesting, maximum 3                                         max-nesting-depth
   6:3   ×  Duplicate property "color"                                          declaration-block-no-duplicate-properties
   6:3   ×  Expected empty line before declaration                              declaration-empty-line-before
  10:1   ×  Expected selector "a" to come before selector ".card a", at line 9  no-descending-specificity
  11:1   ×  Expected empty line before rule                                     rule-empty-line-before

× 9 problems (9 errors, 0 warnings)
  5 errors potentially fixable with the "--fix" option.

Three observations are worth making. The unknown at-rule @foo; on the last line was not reported in this run, so do not assume typos in at-rule names are always caught; test the rule on your own code. First, Stylelint did not complain about @import, even though Dart Sass warns about it. Linters and compilers answer different questions, so you need both. Second, the file contains a duplicate-selector situation (.card appears three times) and Stylelint did not report it with this configuration, because the rule that detects duplicate selectors, no-duplicate-selectors, is not part of what I enabled. If you want it, add "no-duplicate-selectors": true and check the output on your own code, since its behaviour with nested rules can surprise you.

Third, the fix run. Running npx stylelint --fix bad.scss repaired the formatting-type problems, such as the missing blank lines. In my run it also removed the earlier duplicate color declaration, which is a reminder to review the diff after any automatic fix. The naming, nesting and specificity problems stay, because they need human judgement.

Property order

Some teams enforce a property order, such as positioning first, then box model, then typography. Stylelint does not do this in the standard config. The community plugin stylelint-order provides order/properties-order and order/properties-alphabetical-order for it. Many teams skip property ordering entirely, because the tooling effort is high and the benefit is mostly cosmetic. If you adopt it, pick the order once and let --fix apply it, rather than arguing in code review.

What about sass-lint?

Older projects use sass-lint, a linter written specifically for Sass and SCSS. As far as I know, it is no longer actively maintained, and it was built around the older Sass ecosystem and Node versions. I could not verify its current status without internet access, so check the repository before you adopt it. For new projects, Stylelint with the SCSS config is the safer choice because it is actively developed and has a large plugin ecosystem. If you maintain a sass-lint setup, the migration path is to translate its rule list into Stylelint's equivalents and run both for a short period to compare results.

Formatting is a separate job: Prettier

Linting and formatting overlap, but they are different. A formatter rewrites whitespace and punctuation; a linter judges decisions. Prettier can format SCSS. Given a messy file:

.a {
  color: red;
  .b { color: blue }
}
.c{margin:0 auto;padding : 0}

npx prettier pr.scss output:

.a {
  color: red;
  .b {
    color: blue;
  }
}
.c {
  margin: 0 auto;
  padding: 0;
}

Use prettier --check in CI, which exits with a non-zero status when a file would change, and prettier --write locally. Our guides on CSS formatting and architecture and JavaScript code formatting best practices explain why a single automatic formatter ends style debates. If both Prettier and Stylelint enforce spacing rules, they can fight each other. The standard SCSS config already avoids stylistic rules that conflict with Prettier in recent Stylelint versions, but if you add your own formatting rules, test the combination. When a conflict appears, let Prettier own whitespace and let Stylelint own everything else.

Treating deprecations as policy

Dart Sass prints deprecation warnings but still produces CSS. That is helpful for gradual migration and dangerous for forgetting. Decide on a policy and enforce it with flags.

The --fatal-deprecation option turns a named deprecation into an error. I ran it on the division example:

npx sass --fatal-deprecation=slash-div div.scss

The output began with the familiar message and added:

This is only an error because you've set the slash-div deprecation to be fatal.
Remove this setting if you need to keep using this feature.

The process exit code was 65 instead of 0, so a CI pipeline stops. The reverse option, --silence-deprecation=import, hides a specific warning and exited cleanly when I tried it on the @import example. You can also pass --quiet-deps to hide warnings that originate in third-party dependencies, which keeps the log focused on your code.

A sensible policy has three tiers:

  1. Fatal for deprecations you have already fixed. This prevents regressions.
  2. Visible for deprecations you are working on. They stay in the log as a to-do list.
  3. Silenced only for dependencies you cannot change, with a comment explaining why.

Check sass --help for the exact options your installed version supports, because flags have evolved over time.

Enforcing architecture

Syntax and style checks are the foundation. Architecture keeps a stylesheet maintainable once it reaches thousands of lines. Linters can only enforce so much, but they can protect the conventions your team agrees on.

The 7-1 pattern

A popular way to organise Sass is the 7-1 pattern: seven folders and one main file that loads them. A typical layout:

styles/
  abstracts/   variables, functions, mixins
  base/        reset, typography
  components/  buttons, cards, modals
  layout/      header, grid, footer
  pages/       page-specific styles
  themes/      theme variants
  vendors/     third-party styles
  main.scss    loads everything

The pattern is a convention, not a rule of the language. Its value is that anyone can guess where a style lives. With the module system you can make main.scss use each folder's _index.scss, which forwards its partials with @forward.

Partial naming

A file starting with an underscore, such as _buttons.scss, is a partial. Sass does not compile it to its own CSS file, and you load it without the underscore or the extension: @use "components/buttons";. A consistent rule is easy to lint: every file except the entry points must be a partial. You can check this with a small script in CI, or with the SCSS plugin rule scss/at-import-partial-extension for the extension part.

BEM naming

BEM (Block, Element, Modifier) names such as card__title--large map well to SCSS nesting with &:

.card {
  padding: 1rem;

  &__title {
    font-size: 1.25rem;

    &--large {
      font-size: 2rem;
    }
  }
}

This compiles to flat selectors: .card, .card__title and .card__title--large. That is the real advantage of BEM with Sass: you get readable source and low-specificity output. The selector-class-pattern rule shown earlier enforces the naming. One caution: &__title builds a selector at compile time, so a search for card__title in your editor will not find the source. That is a trade-off to discuss with your team, and nesting depth limits keep it manageable.

Limits worth setting

  • Nesting depth of three or less. Specificity grows with each level, and the compiled selector is the concatenation of all of them.
  • No @extend across files without a placeholder. @extend rewrites selectors elsewhere in the output, which makes compiled CSS surprising.
  • No !important except in utilities. The declaration-no-important rule will help.
  • One module per concern. If a partial both defines variables and outputs CSS, loading it twice outputs the CSS twice.

For the compiled-output side of this story, read CSS minification and critical CSS, which explains what happens after Sass has done its work.

Step by step: validating in the browser with DevUtilsX

Sometimes you just need an answer: does this snippet compile, and what is wrong with it? You do not want to set up a project to find out. The DevUtilsX validators run in your browser, so what you paste is processed locally and never uploaded to a server. That matters when your stylesheet contains internal class names, client work or unreleased designs.

For SCSS code:

  1. Open the SCSS Validator.
  2. Paste your SCSS into the input area. Include the whole block you are working on, because a brace error can only be found when the parser sees the end.
  3. Run the validation and read the message. Look at the line and column, then apply the same rules as with Dart Sass: a missing semicolon is usually reported on the line after the real problem, and a missing brace at the end of the file.
  4. Fix the issue in your editor, paste again and repeat until the result is clean.
  5. If a snippet depends on variables or mixins defined in other files, paste those definitions above it temporarily, or replace them with stand-ins. A single-snippet validator cannot see your other partials, so "undefined variable" results for names that live elsewhere are expected, not a bug.

For the indented syntax:

  1. Open the Sass Validator.
  2. Paste the indented code. Make sure the editor you copy from converts tabs to spaces, because mixed indentation is the single most common failure in this syntax.
  3. Read the result. Messages about indentation refer to the number of spaces expected, so count the leading spaces on the line reported.
  4. Remember that a successful result only says the code parses and compiles. Check the compiled CSS for surprises such as the nested-property case earlier.

A browser validator is ideal for quick checks, for teaching, and for reproducing a bug report in isolation. For a full project with dozens of partials, use the command-line tools described next, because they can resolve every @use and run on every commit.

Code examples you can run

The commands in this section were run in a scratch project with sass, stylelint, stylelint-config-standard-scss and prettier installed from npm. The Sass package reported version 1.107.1 (compiled with dart2js), Stylelint 17.16.0 and Prettier 3.9.10 on the machine I used; your versions may differ, and messages can change between releases.

Node.js: compile and collect warnings

The JavaScript API lets you catch errors as exceptions and intercept warnings with a custom logger. This script treats both errors and deprecations as failures:

import * as sass from "sass";

const files = process.argv.slice(2);
let failed = 0;
for (const file of files) {
  const warnings = [];
  try {
    sass.compile(file, {
      logger: {
        warn(message, { deprecation, deprecationType }) {
          const first = message.split("\n")[0];
          warnings.push(deprecation ? `[${deprecationType.id}] ${first}` : first);
        },
        debug() {},
      },
    });
    console.log(`${file}: OK${warnings.length ? ` with ${warnings.length} warning(s)` : ""}`);
    warnings.forEach((w) => console.log("  warn:", w));
    if (warnings.length) failed = 1;
  } catch (e) {
    failed = 1;
    const s = e.span;
    console.log(`${file}: ERROR ${e.sassMessage} at ${s.start.line + 1}:${s.start.column + 1}`);
  }
}
process.exit(failed);

Running it on a clean file, two broken files and two files with deprecations produced:

ok.scss: OK
missing-semi.scss: ERROR expected ";". at 3:10
undef-var.scss: ERROR Undefined variable. at 2:10
div.scss: OK with 1 warning(s)
  warn: [slash-div] Using / for division outside of calc() is deprecated and will be removed in Dart Sass 2.0.0.
imp.scss: OK with 1 warning(s)
  warn: [import] Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.

The process exited with status 1 because at least one file failed. Notice that the line numbers in the exception span are zero-based, which is why the script adds one before printing.

Python: wrap the Sass command line

Python has no official Dart Sass binding, but the command-line tool is easy to call. This script classifies each file as ok, warning or error and returns the worst result as its exit code:

import re, subprocess, sys

def check(path):
    p = subprocess.run(["npx", "sass", "--no-source-map", path],
                       capture_output=True, text=True)
    err = p.stderr
    first = err.strip().splitlines()[0] if err.strip() else ""
    loc = re.search(r"^\s+(\S+) (\d+):(\d+)\s", err, re.M)
    kind = "error" if p.returncode else ("warning" if "DEPRECATION WARNING" in err else "ok")
    return kind, first, (loc.group(2), loc.group(3)) if loc else None

worst = 0
for f in sys.argv[1:]:
    kind, msg, loc = check(f)
    print(f"{f}: {kind}" + (f" at {loc[0]}:{loc[1]}" if loc else "") + (f" - {msg}" if msg else ""))
    worst = max(worst, {"ok": 0, "warning": 1, "error": 2}[kind])
sys.exit(worst)

Run on four files, it printed:

ok.scss: ok
missing-brace.scss: error at 5:2 - Error: expected end of rule.
imp.scss: warning at 1:9 - DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.
undef-mixin.scss: error at 2:3 - Error: Undefined mixin.

Exit code 2 meant at least one error. The regular expression relies on the format of the location line at the bottom of the message, so if a future Sass release changes it, adjust the pattern.

Shell: a CI sequence

The last example is a three-step script suitable for a CI job. It runs the linter, the format check and a compile with a fatal deprecation:

#!/usr/bin/env bash
set -euo pipefail
npx stylelint "src/**/*.scss"
npx prettier --check "src/**/*.scss"
npx sass --no-source-map --fatal-deprecation=slash-div --load-path=node_modules src/main.scss > /dev/null

On a clean project it printed Checking formatting... and All matched files use Prettier code style! and exited with status 0. The set -euo pipefail line is what makes the script stop at the first failure, and sending the compiled CSS to /dev/null means the job validates without producing artefacts. Replace that with a real output path if the same job also builds the stylesheet.

Editor, pre-commit and CI integration

A check that runs only when someone remembers to run it will be skipped. Put the checks where work already happens.

In the editor

Most editors can run Stylelint as you type through an extension that reads your project's configuration. Install the Stylelint extension for your editor, enable it for SCSS files, and the same rules you use in CI show up as underlines in the file. Enable format-on-save with Prettier so that whitespace never reaches the linter. For the indented syntax, make sure the editor uses spaces and displays whitespace. This one setting prevents the "Expected spaces, was tabs" error entirely.

As a pre-commit hook

A pre-commit hook catches problems before they reach the repository. The popular lint-staged package runs commands only on the staged files, which keeps the hook fast. A minimal configuration in package.json:

{
  "lint-staged": {
    "*.{scss,sass}": ["stylelint --fix", "prettier --write"]
  }
}

You then connect it to a Git hook with a tool such as Husky. Keep hooks fast, because developers who wait ten seconds on every commit will learn to bypass them. Leave the slow checks, such as a full compile of every entry point, for CI.

In CI

CI is the enforcement layer because it cannot be skipped. The script from the previous section is a good start. A few practical points:

  • Compile every entry point, not just one. A partial that is only used by a rarely built page can break unnoticed.
  • Pin the versions of sass and stylelint through your lock file so that an upgrade never fails a build you did not touch. Upgrade deliberately in its own change, and read the release notes for new deprecations.
  • Fail on lint errors but allow warnings at first when you introduce a rule to an existing codebase, then tighten.
  • Cache node_modules to keep the job fast.

Performance, security and accessibility

Validation is mostly a quality topic, but three side issues are worth a short mention.

Performance. Compile time grows with the size of the dependency graph. The module system helps, because each module is loaded and evaluated once, whereas @import re-evaluates a file every time it is imported. Deeply nested selectors and heavy use of @extend increase both compile time and CSS size. Run a compile with timing on your largest entry point before and after a refactor; a measurement on your own code is worth more than a general rule. Once the CSS is generated, minify it, as described in our CSS minification guide.

Security. Sass itself does not execute arbitrary code, but the build around it does. Treat third-party Sass packages like any dependency: pin versions, review updates and avoid loading stylesheets from paths you do not control. Be careful with --load-path and untrusted input. If you build an online service that compiles user-submitted Sass, set resource limits, because a malicious file could loop or request very large output. The browser-based validators in DevUtilsX avoid the problem for ordinary use because the code never leaves your machine.

Accessibility. Sass code can produce inaccessible CSS in ways a compiler cannot see. Removing focus outlines with outline: none, relying on colour alone, using tiny font sizes, and hiding content with display: none when it should remain available to screen readers are all valid Sass. Stylelint can enforce a few of them, for example by banning outline: none through the declaration-property-value-disallowed-list rule. Contrast ratios and motion preferences need separate tooling, such as an accessibility audit in the browser and a prefers-reduced-motion media query in your base styles.

Troubleshooting: symptom, cause, fix

Symptom Likely cause Fix
expected end of rule at the last line of the file A block was opened with { and never closed Search upward for the unbalanced brace; use an editor that highlights matching braces
expected ";" pointing at the line after your code The previous declaration has no semicolon Add a semicolon to the line above the caret
expected ")" inside a map Missing comma between two key-value pairs Add the comma; adopt trailing commas to make the pattern easier to spot
Expected spaces, was tabs in a .sass file Mixed tab and space indentation Convert tabs to spaces and turn on whitespace display
Inconsistent indentation, expected N spaces A line is indented differently from its siblings Align the line with the first line of the block
A property like color-padding appears in the output A line was indented one level too deep and became a nested property Fix the indentation, then check the compiled CSS
Undefined variable for a variable that exists The defining file is not loaded, or it is used without its namespace Add @use and write module.$name
Undefined mixin Missing @use, or the mixin name has a typo Load the module and prefix with the namespace
@use rules must be written before any other rules A @use appears after a style rule Move every @use to the top of the file
Module loop: this module is already being loaded Two files load each other Extract shared code into a third file
Can't find stylesheet to import Wrong path, or missing load path Check the path, partial names and --load-path
The target selector was not found @extend targets a selector that does not exist Define it, use a placeholder, or switch to a mixin
A deprecation warning on @import or / Legacy syntax in use Migrate to @use and math.div; make the deprecation fatal after fixing
CI fails but your machine passes Different Sass or Stylelint version, or a missing load path Pin versions in the lock file and match the CI flags locally
Stylelint reports every @include as unknown The CSS at-rule-no-unknown rule is on Set it to null and enable scss/at-rule-no-unknown
Stylelint cannot parse a // comment The SCSS syntax is not configured for that file type Extend stylelint-config-standard-scss, which sets postcss-scss for SCSS files

When an error message does not make sense, shrink the problem. Copy the failing rule into a new file and compile that alone. If the small file compiles, the problem is in how your files depend on each other. If it still fails, you have a minimal example you can paste into the SCSS Validator or share with a colleague. The Code Diff guide covers a related technique: comparing the last working version with the broken one to find what changed.

Best practices checklist

Use this list as a starting point for your own team rules.

Compile correctly

  • Use Dart Sass, the actively maintained implementation.
  • Prefer @use and @forward to @import in all new code.
  • Use math.div or calc() instead of / for division.
  • Keep @use rules at the top of every file.
  • Break circular dependencies by extracting shared code.

Validate the output

  • Review the compiled CSS when you change a file's structure.
  • Run a CSS-level check (property-no-unknown or a CSS validator) because Sass passes unknown properties through.
  • Test visually where styling is critical.

Lint consistently

  • Extend stylelint-config-standard-scss and add only the rules your team will honour.
  • Replace at-rule-no-unknown with scss/at-rule-no-unknown.
  • Limit nesting to three levels and enforce a class naming pattern.
  • Add no-descending-specificity and treat it as a signal that the selector order needs thought.
  • Review changes made by --fix.

Format automatically

  • Let Prettier own whitespace and let Stylelint own decisions.
  • Use two-space indentation without tabs, especially for the indented syntax.

Automate

  • Run lint-staged in a pre-commit hook for speed.
  • Run the full lint, format check and compile in CI with every entry point.
  • Pin tool versions and upgrade on purpose.

Manage deprecations

  • Watch the warnings, fix them in batches and make fixed ones fatal with --fatal-deprecation.
  • Silence a deprecation only for dependencies you cannot change, and leave a comment saying so.

Organise

  • Follow a consistent structure such as the 7-1 pattern.
  • Name partials with a leading underscore.
  • Use BEM or another naming convention and enforce it with a pattern rule.

Comparing validation and linting options

No single tool covers all three layers. This table compares the main options.

Option Checks syntax and names Checks compiled CSS validity Style and architecture rules Best for
Dart Sass compiler (sass) Yes, authoritative No (passes unknown properties) No Ground truth for whether the code compiles
Stylelint with stylelint-config-standard-scss Partly (parse errors, unknown at-rules) Partly (unknown properties, invalid patterns) Yes, highly configurable Day-to-day linting in editor and CI
sass-lint Partly No Yes Legacy projects; check its maintenance status before adopting
Prettier Parse errors only No Formatting only Automatic formatting
W3C CSS Validator on compiled output No Yes No Final check of generated CSS
SCSS Validator and Sass Validator (browser) Yes, for the pasted snippet Not a substitute for a full CSS check No Quick checks, teaching, bug reports, private code
Editor language support Yes, as you type Partly Depends on plugins Immediate feedback

The best setup combines a compiler for truth, Stylelint for rules, Prettier for formatting and a browser validator for quick questions.

FAQ

What is the difference between Sass and SCSS?

They are two syntaxes for the same language. SCSS uses curly braces and semicolons, and every valid CSS file is valid SCSS. The indented Sass syntax drops braces and semicolons and uses indentation. Both compile with the same compiler, and the same features work in both.

Is Sass validation the same as CSS validation?

No. Sass validation checks that your source compiles, which involves variables, modules and control flow. CSS validation checks the generated output against the CSS specifications. A file can pass the first and fail the second, as the colour: red; example showed.

Which compiler should I use today?

Dart Sass, installed as the sass package on npm. It is the reference implementation described in the official documentation. The older Ruby Sass and LibSass implementations are no longer the recommended choice.

Why does my error point to a line that looks correct?

The parser reports where it noticed the problem, not where you made it. A missing semicolon is reported on the next line, and a missing closing brace is reported at the end of the file. Look at the line above the caret, then scan upward for unbalanced braces.

Do I have to stop using @import right now?

It still works in Dart Sass at the time of writing, but the compiler prints a deprecation warning saying it will be removed in a future major version. Plan a migration to @use and @forward, and use the warning as your to-do list. Making the deprecation fatal after you finish prevents it from returning.

Why does Stylelint complain about @include and @mixin?

The core at-rule-no-unknown rule only knows CSS at-rules. Turn it off and enable scss/at-rule-no-unknown, or extend stylelint-config-standard-scss, which already makes that change.

Can I lint the indented .sass syntax with Stylelint?

Stylelint's SCSS support targets the .scss syntax. Support for the indented syntax is limited, so for .sass files rely mainly on the compiler and on an editor configured with spaces. Check the current Stylelint and postcss-scss documentation for the latest status, because I could not verify it here.

Should Prettier and Stylelint both check formatting?

Choose one owner for whitespace. The common choice is Prettier for formatting and Stylelint for everything else. If a rule from Stylelint conflicts with Prettier's output, disable the Stylelint rule.

What does --fatal-deprecation do?

It tells Dart Sass to treat named deprecations as errors. With --fatal-deprecation=slash-div, using / for division stopped the compile and the process returned exit code 65 in my test. It is useful in CI once you have fixed a particular deprecation.

Are the browser validators safe for private code?

The DevUtilsX validators run locally in your browser, and the pasted code is not uploaded. They validate a single pasted snippet, so they cannot see other files in your project. For whole-project checks, use the command line.

Conclusion

Validating Sass means answering three separate questions. Does the source compile with Dart Sass? Is the CSS it produces valid? Does the code follow the rules your team agreed on? The compiler gives precise, readable errors once you learn that carets often point one line late and braces fail at the end of the file. Stylelint with the SCSS config catches naming, nesting and specificity problems the compiler never will. Prettier removes formatting arguments, and CI with fatal deprecations keeps legacy syntax from creeping back.

Start small. Paste a troublesome stylesheet into the SCSS Validator or the Sass Validator, fix what it finds, and then add Stylelint and a CI job so the same checks run on every commit. All processing happens in your browser, so you can try it on real code.

To go further, read CSS Formatting and Architecture for the conventions behind the rules, and CSS Minification and Critical CSS for what to do with the compiled output.

Further reading

Try the tools

Further reading

Related articles