Harness the Combinatoric Power of Command-Line Tools and Utilities
Lint Your Markdown with markdownlint
Published March 19, 2026
Introduction
Markdown is flexible, but when multiple people contribute to the same documentation, you can end up with inconsistencies. One author uses asterisks for lists, while another uses dashes. Someone forgets blank lines around headings. Another person indents with tabs instead of spaces. The rendered output might look fine, but the source files become inconsistent and harder to maintain.
Markdownlint checks Markdown files for structural and formatting issues. It catches things like inconsistent heading styles, missing blank lines, trailing whitespace, and improper list indentation.
Markdownlint focuses on structure, not prose. It won’t tell you that your sentences are too long or that you’re using passive voice. To solve for those cases, use Vale instead. If you want to dig deeper into Vale, check out Write Better with Vale. Together, markdownlint and Vale give you comprehensive coverage: one handles the structure of your Markdown, the other handles the quality of your writing.
To explore how Markdownlint works, you’ll create a Markdown document with some issues, run Markdownlint against the file, configure it to ignore certain rules, and then write a custom rule.
What You Need
To complete this tutorial, you need:
- Node.js installed, which you can do by following the Install Node.js tutorial.
- On macOS, you need Homebrew installed, which you can do by following the Install Homebrew tutorial.
Setting Up a Project
To use Markdownlint, you’ll create a small project and install Markdownlint’s command-line tool as a project dependency using npm.
Create a directory for this project and switch to it:
mkdir markdown-lint-demoSwitch to the directory you just created:
cd markdown-lint-demoInitialize a new npm project in this directory using the defaults:
npm init -yNow install the Markdownlint CLI tool as a project dependency:
npm install markdownlint-cli2The markdownlint-cli2 package is the modern command-line interface for markdownlint. It lets you use a single .markdownlint-cli2.jsonc configuration file that handles rule settings, file ignores, and custom rules all in one place.
Now you need a file with some errors so you can lint it. Create a file called example.md with the following content:
# My Article
This paragraph has no blank line above it.
## Installation
First, download the package. This line has a trailing tab.
#### Deep Heading
This heading skipped a level.
## Getting Started
Some content here.
This file has several structural problems. The first paragraph has no blank line separating it from the heading. The “Installation” heading has an extra space after the hashes. There’s a trailing tab character on one line. The “Deep Heading” section skips from level 2 to level 4. And “Getting Started” has no blank line before it. This file would absolutely render, but it could cause problems and would be inconsistent.
Now run Markdownlint against the sample file:
npx markdownlint-cli2 example.mdThe output shows each issue found:
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 5 error(s)
example.md:1 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "# My Article"]
example.md:3:4 error MD019/no-multiple-space-atx Multiple spaces after hash on atx style heading [Context: "## Installation"]
example.md:3 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Above] [Context: "## Installation"]
example.md:7 error MD001/heading-increment Heading levels should only increment by one level at a time [Expected: h3; Actual: h4]
example.md:11 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## Getting Started"]
Each line tells you the file, line number, rule ID, rule name, and a description of the problem. The rule IDs like MD019 and MD022 are useful when you want to ignore specific rules.
Configure Markdownlint to Ignore Rules
You might not want to use every rule. Maybe your team allows skipped heading levels for stylistic reasons, or you don’t care about trailing whitespace because your editor strips it when you save. You can turn rules off by using a configuration file.
Create a file called .markdownlint-cli2.jsonc in your project directory. Add the following code to the file to configure some rules:
{
"config": {
"default": true,
"MD001": false,
"MD009": false
}
}
This configuration starts with all default rules enabled, then disables MD001, which enforces heading level increments, and MD009, which flags trailing whitespace.
Run Markdownlint again:
npx markdownlint-cli2 example.mdThe output now shows fewer errors:
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 4 error(s)
example.md:1 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "# My Article"]
example.md:3:4 error MD019/no-multiple-space-atx Multiple spaces after hash on atx style heading [Context: "## Installation"]
example.md:3 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Above] [Context: "## Installation"]
example.md:11 error MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## Getting Started"]
The heading increment and trailing whitespace violations are gone.
Automatically Fix Errors
Markdownlint can fix your files for you. Run markdownlint-cli2 with the --fix flag:
npx markdownlint-cli2 --fix example.mdThe results now show that things are fixed:
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 0 error(s)
Markdownlint can’t automatically fix everything, but it can fix basic things like extra spaces, indentation issues, or missing blank lines around elements. This can be a huge time-saver overall.
Writing a Custom Rule
The built-in rules cover common issues, but sometimes you need project-specific checks. For example, suppose your style guide requires every article to end with a “Conclusion” section. You won’t find a built-in rule to enforce this, but you can write your own custom rule.
Create a file called custom-rules.js to hold the rule definition. Add the following code to define a rule that finds all level-2 headings, then checks if the last one is ## Conclusion:
module.exports = {
names: ["require-conclusion"],
description: "Documents must end with a ## Conclusion heading",
tags: ["headings"],
function: function rule(params, onError) {
const lines = params.lines;
// Find all h2 headings
const h2Headings = [];
lines.forEach((line, index) => {
if (/^## /.test(line)) {
h2Headings.push({ line: line, lineNumber: index + 1 });
}
});
// Check if there are any H2 headings
if (h2Headings.length === 0) {
onError({
lineNumber: lines.length,
detail: "No ## headings found"
});
return;
}
// Check if the last h2 heading is "## Conclusion"
const lastH2 = h2Headings[h2Headings.length - 1];
if (!/^## Conclusion\s*$/.test(lastH2.line)) {
onError({
lineNumber: lastH2.lineNumber,
detail: "Last ## heading must be '## Conclusion'",
context: lastH2.line
});
}
}
};
The rule exports an object with a few properties. The names array contains identifiers for the rule. The description explains what the rule checks. The tags array groups related rules together. The function property contains the actual check logic.
The function receives two arguments: params, which contains information about the document, and onError, a callback function you call when you find a violation. The params.lines array contains each line of the document as a string.
When the function is unable to find the “Conclusion” header, it executes the onError callback, sending back the line number, a message, and the text of the line. That’s what markdownlint displays in its output.
To use the custom rule, pass it to markdownlint with the --rules flag:
npx markdownlint-cli2 --rules ./custom-rules.js example.mdThe output now includes your custom rule violation:
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: example.md
Linting: 1 file(s)
Summary: 1 error(s)
example.md:13 error require-conclusion Documents must end with a ## Conclusion heading [Last ## heading must be '## Conclusion'] [Context: "## Getting Started"]
Markdownlint can’t automatically fix this issue when you use the --fix flag. Add a ## Conclusion section to the end of your document to fix the issue manually.
You can also load custom rules through the configuration file instead of passing the --rules flag every time. Update your .markdownlint-cli2.jsonc:
{
// Load custom rules from a local file
"customRules": ["./custom-rules.js"],
// Standard rule configuration
"config": {
"default": true,
"MD001": false,
"MD009": false
}
}
Run Markdownlint without specifying the rules flag:
npx markdownlint-cli2 example.mdNow Markdownlint loads your custom rules along with the other configuration changes you made.
Ignoring Files
You can run markdownlint against all files in the current directory and child directories, but then you might scan things you don’t care about. To ignore specific files, add an ignores array to your configuration. For example, add the following to ignore the node_modules directory, the vendor/ folder, and the CHANGELOG.md file:
{
"ignores": ["node_modules/", "vendor/", "CHANGELOG.md"],
"config": {
"default": true
}
}
Now run the command against all Markdown files in the current directory and its children:
npx markdownlint-cli2 "**/*.md"This time the output shows the errors as well as the files that markdownlint ignored:
markdownlint-cli2 v0.20.0 (markdownlint v0.40.0)
Finding: **/*.md !node_modules/ !vendor/ !CHANGELOG.md
Linting: 1 file(s)
Summary: 1 error(s)
example.md:13 error require-conclusion Documents must end with a ## Conclusion heading [Last ## heading must be '## Conclusion'] [Context: "## Getting Started"]
With this, you can run the tool against all of your project’s documentation without having to review things you don’t need to be responsible for.
Conclusion
Now that you have Markdownlint working, tie it into your workflow. The VS Code extension highlights issues as you write, so you get real-time feedback. You can also add markdownlint to your CI pipeline to catch issues before you publish. And you can combine markdownlint with Vale to cover both structure and prose as part of your publication flow.
Consistent documentation is easier to read, easier to maintain, and easier to convert to other formats. Set up the rules once, and let the tools do the enforcement for you.