Batch Convert Markdown to Word: Process Multiple Files at Once

When you have dozens or even hundreds of Markdown files to convert to Word, doing them one by one is simply not practical. This guide walks you through three proven methods to bulk convert multiple .md files to .docx format -- from simple shell scripts to full Node.js automation and CI/CD pipelines.

Updated: March 2026 12 min read

3 Approaches Compared

Before diving into each method, here is a quick comparison to help you choose the right approach for your situation:

Feature Pandoc Script Node.js Automation Online Tools
Setup Difficulty Easy (install Pandoc) Medium (Node.js + packages) None
Max File Volume Unlimited Unlimited ~10-20 files
Formatting Control Excellent (reference-doc) Full programmatic control Basic
CI/CD Integration Straightforward Straightforward Not supported
Custom Styling Via reference .docx Via code (headers, fonts, etc.) Limited
Best For Quick bulk conversions Complex workflows Small one-off batches

Method 1: Pandoc Batch Conversion

Pandoc is the gold standard for document conversion. It runs on Windows, macOS, and Linux, and supports Markdown to DOCX natively. With a simple shell loop, you can convert hundreds of files in seconds.

Prerequisites

Install Pandoc from the official site or via a package manager:

# macOS
brew install pandoc

# Ubuntu / Debian
sudo apt-get install pandoc

# Windows (via Chocolatey)
choco install pandoc

Bash Script (macOS / Linux)

Save the following script as batch_convert.sh in the root of your Markdown folder:

#!/bin/bash
# batch_convert.sh - Convert all .md files to .docx using Pandoc
# Usage: bash batch_convert.sh [input_dir] [output_dir]

INPUT_DIR="${1:-.}"
OUTPUT_DIR="${2:-./docx_output}"

mkdir -p "$OUTPUT_DIR"

# Count total files for progress tracking
TOTAL=$(find "$INPUT_DIR" -name "*.md" -type f | wc -l)
COUNT=0

echo "Found $TOTAL Markdown files in $INPUT_DIR"
echo "Output directory: $OUTPUT_DIR"
echo "---"

find "$INPUT_DIR" -name "*.md" -type f | while read -r file; do
    COUNT=$((COUNT + 1))

    # Preserve relative directory structure
    REL_PATH="${file#$INPUT_DIR/}"
    REL_DIR=$(dirname "$REL_PATH")
    BASENAME=$(basename "$file" .md)

    # Create output subdirectory if needed
    mkdir -p "$OUTPUT_DIR/$REL_DIR"

    OUTPUT_FILE="$OUTPUT_DIR/$REL_DIR/$BASENAME.docx"

    echo "[$COUNT/$TOTAL] Converting: $REL_PATH"
    pandoc "$file" -o "$OUTPUT_FILE" \
        --from markdown \
        --to docx \
        --standalone

    if [ $? -eq 0 ]; then
        echo "         -> $OUTPUT_FILE"
    else
        echo "  ERROR: Failed to convert $file" >&2
    fi
done

echo "---"
echo "Batch conversion complete!"

Make it executable and run:

chmod +x batch_convert.sh
./batch_convert.sh ./docs ./output

PowerShell Script (Windows)

For Windows users, save this as batch_convert.ps1:

# batch_convert.ps1 - Convert all .md files to .docx using Pandoc
# Usage: .\batch_convert.ps1 -InputDir .\docs -OutputDir .\docx_output

param(
    [string]$InputDir = ".",
    [string]$OutputDir = ".\docx_output"
)

# Create output directory
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null

$files = Get-ChildItem -Path $InputDir -Filter "*.md" -Recurse
$total = $files.Count
$count = 0

Write-Host "Found $total Markdown files in $InputDir"
Write-Host "Output directory: $OutputDir"
Write-Host "---"

foreach ($file in $files) {
    $count++

    # Preserve relative directory structure
    $relativePath = $file.FullName.Substring((Resolve-Path $InputDir).Path.Length + 1)
    $relativeDir = Split-Path $relativePath -Parent
    $baseName = $file.BaseName

    $outputSubDir = Join-Path $OutputDir $relativeDir
    New-Item -ItemType Directory -Force -Path $outputSubDir | Out-Null

    $outputFile = Join-Path $outputSubDir "$baseName.docx"

    Write-Host "[$count/$total] Converting: $relativePath"

    pandoc $file.FullName -o $outputFile `
        --from markdown `
        --to docx `
        --standalone

    if ($LASTEXITCODE -eq 0) {
        Write-Host "         -> $outputFile"
    } else {
        Write-Error "Failed to convert $($file.FullName)"
    }
}

Write-Host "---"
Write-Host "Batch conversion complete!"

Run it from PowerShell:

.\batch_convert.ps1 -InputDir .\my-markdown-files -OutputDir .\converted

Tip: Both scripts preserve the original folder hierarchy. If your source has docs/api/README.md, the output will be output/api/README.docx.

Method 2: Node.js Automation

Why Node.js? When you need fine-grained control over headings, fonts, table styles, or want to inject metadata into each document, a programmatic approach gives you full flexibility. This method uses the unified ecosystem to parse Markdown and the docx library to build Word files from scratch.

Project Setup

mkdir md-to-docx-batch && cd md-to-docx-batch
npm init -y
npm install unified remark-parse remark-stringify docx glob fs-extra

Conversion Script

Create convert.js:

const fs = require("fs-extra");
const path = require("path");
const { glob } = require("glob");
const { unified } = require("unified");
const remarkParse = require("remark-parse");
const { Document, Packer, Paragraph, TextRun, HeadingLevel,
        AlignmentType, Table, TableRow, TableCell, BorderStyle } = require("docx");

// Configuration
const INPUT_DIR = process.argv[2] || "./markdown";
const OUTPUT_DIR = process.argv[3] || "./docx_output";
const FONT_FAMILY = "Calibri";
const FONT_SIZE = 24; // Half-points (24 = 12pt)

// Parse Markdown AST into docx Paragraphs
function astToDocxChildren(node) {
    const children = [];

    for (const child of node.children || []) {
        switch (child.type) {
            case "heading": {
                const levels = {
                    1: HeadingLevel.HEADING_1,
                    2: HeadingLevel.HEADING_2,
                    3: HeadingLevel.HEADING_3,
                };
                const text = extractText(child);
                children.push(
                    new Paragraph({
                        heading: levels[child.depth] || HeadingLevel.HEADING_3,
                        children: [new TextRun({ text, font: FONT_FAMILY })],
                    })
                );
                break;
            }
            case "paragraph": {
                const runs = extractRuns(child);
                children.push(new Paragraph({ children: runs }));
                break;
            }
            case "code": {
                children.push(
                    new Paragraph({
                        children: [
                            new TextRun({
                                text: child.value,
                                font: "Courier New",
                                size: 20,
                            }),
                        ],
                        spacing: { before: 120, after: 120 },
                    })
                );
                break;
            }
            case "list": {
                for (const item of child.children) {
                    const text = extractText(item);
                    children.push(
                        new Paragraph({
                            children: [
                                new TextRun({ text: `  \u2022  ${text}`, font: FONT_FAMILY, size: FONT_SIZE }),
                            ],
                        })
                    );
                }
                break;
            }
            case "blockquote": {
                const text = extractText(child);
                children.push(
                    new Paragraph({
                        children: [
                            new TextRun({ text, italics: true, font: FONT_FAMILY, size: FONT_SIZE }),
                        ],
                        indent: { left: 720 },
                    })
                );
                break;
            }
            default:
                break;
        }
    }

    return children;
}

// Helper: extract plain text from an AST node
function extractText(node) {
    if (node.value) return node.value;
    if (node.children) return node.children.map(extractText).join("");
    return "";
}

// Helper: extract TextRuns with basic formatting
function extractRuns(node) {
    const runs = [];
    for (const child of node.children || []) {
        if (child.type === "text") {
            runs.push(new TextRun({ text: child.value, font: FONT_FAMILY, size: FONT_SIZE }));
        } else if (child.type === "strong") {
            runs.push(new TextRun({ text: extractText(child), bold: true, font: FONT_FAMILY, size: FONT_SIZE }));
        } else if (child.type === "emphasis") {
            runs.push(new TextRun({ text: extractText(child), italics: true, font: FONT_FAMILY, size: FONT_SIZE }));
        } else if (child.type === "inlineCode") {
            runs.push(new TextRun({ text: child.value, font: "Courier New", size: 20 }));
        } else if (child.type === "link") {
            runs.push(new TextRun({ text: extractText(child), font: FONT_FAMILY, size: FONT_SIZE, color: "2563EB" }));
        } else {
            runs.push(new TextRun({ text: extractText(child), font: FONT_FAMILY, size: FONT_SIZE }));
        }
    }
    return runs;
}

async function convertFile(inputPath, outputPath) {
    const markdown = await fs.readFile(inputPath, "utf-8");

    const tree = unified().use(remarkParse).parse(markdown);
    const docChildren = astToDocxChildren(tree);

    const doc = new Document({
        sections: [{ children: docChildren }],
    });

    const buffer = await Packer.toBuffer(doc);
    await fs.ensureDir(path.dirname(outputPath));
    await fs.writeFile(outputPath, buffer);
}

async function main() {
    const files = await glob("**/*.md", { cwd: INPUT_DIR });
    console.log(`Found ${files.length} Markdown files in ${INPUT_DIR}`);

    let success = 0;
    let failed = 0;

    for (const file of files) {
        const inputPath = path.join(INPUT_DIR, file);
        const outputPath = path.join(OUTPUT_DIR, file.replace(/\.md$/, ".docx"));

        try {
            await convertFile(inputPath, outputPath);
            console.log(`  Converted: ${file}`);
            success++;
        } catch (err) {
            console.error(`  FAILED: ${file} - ${err.message}`);
            failed++;
        }
    }

    console.log(`\nDone! ${success} converted, ${failed} failed.`);
}

main().catch(console.error);

Run the script:

node convert.js ./my-markdown-files ./word-output

This approach is ideal when you need to programmatically control fonts, heading sizes, page margins, or inject custom metadata like author names and creation dates into each document. You can extend the astToDocxChildren function to handle tables, images, horizontal rules, and any other Markdown element your documents use.

Method 3: Online Tools for Small Batches

If you only have a handful of files (under 10-20) and do not want to install any software, browser-based converters are the fastest path. Our own Markdown to Word converter handles this elegantly.

Step-by-Step with Our Converter

  1. Open the Markdown to Word converter in your browser.
  2. Paste the contents of your first Markdown file into the editor, or drag-and-drop the .md file directly.
  3. Preview the rendered output to verify formatting is correct.
  4. Click "Download DOCX" to save the Word file.
  5. Repeat for each remaining file. The converter runs entirely in your browser, so your data never leaves your machine.

When to Use Online Tools vs Scripts

Use Online Tools When:

  • - You have fewer than 20 files
  • - You need a quick one-time conversion
  • - You cannot install software on your machine
  • - You want to preview before downloading

Use Scripts When:

  • - You have 20+ files or convert regularly
  • - You need consistent, repeatable output
  • - You want to integrate with CI/CD
  • - You need custom styling or metadata

Folder Structure and Output Organization

When batch converting, maintaining an organized output structure is critical. Both the Bash and Node.js scripts above preserve the source folder hierarchy by default. Here is a practical example of what that looks like:

# Source structure
docs/
  README.md
  api/
    endpoints.md
    authentication.md
  guides/
    getting-started.md
    advanced-usage.md
    troubleshooting.md

# After batch conversion
docx_output/
  README.docx
  api/
    endpoints.docx
    authentication.docx
  guides/
    getting-started.docx
    advanced-usage.docx
    troubleshooting.docx

Tips for Large Projects

For the Bash script, add an exclusion pattern by modifying the find command:

find "$INPUT_DIR" -name "*.md" -type f \
    -not -path "*/node_modules/*" \
    -not -name "CHANGELOG.md" \
    | while read -r file; do
    # ... conversion logic
done

Maintaining Consistent Styles Across All Documents

One of the biggest challenges with batch conversion is ensuring every Word document looks identical -- same fonts, heading sizes, margins, and spacing. Pandoc solves this elegantly with the --reference-doc parameter.

Creating a Reference Document

  1. Generate a default reference: Run pandoc -o custom-reference.docx --print-default-data-file reference.docx
  2. Open custom-reference.docx in Word and modify the styles:
    • Set your desired font family and sizes for Normal, Heading 1, Heading 2, Heading 3
    • Configure page margins (e.g., 2.5 cm left/right, 1 cm top/bottom)
    • Set line spacing, paragraph spacing, and indentation
    • Customize table styles, code block formatting, and blockquote styles
  3. Save the file -- this becomes your brand template.

Using the Reference Document in Batch Scripts

Update the Pandoc command in your batch script to include the reference document:

pandoc "$file" -o "$OUTPUT_FILE" \
    --from markdown \
    --to docx \
    --standalone \
    --reference-doc=custom-reference.docx

Every converted document will now inherit the styles defined in your reference file. This is particularly powerful for corporate documentation where brand consistency matters.

Pro Tip: Store your custom-reference.docx in version control alongside your Markdown source. This way, style updates propagate automatically when you re-run the batch conversion.

CI/CD Integration with GitHub Actions

Automate your batch conversions so that every time you push Markdown changes, Word documents are generated automatically. Here is a complete GitHub Actions workflow:

# .github/workflows/convert-markdown.yml
name: Batch Convert Markdown to Word

on:
  push:
    paths:
      - 'docs/**/*.md'
  workflow_dispatch:

jobs:
  convert:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Pandoc
        run: |
          sudo apt-get update
          sudo apt-get install -y pandoc

      - name: Run batch conversion
        run: |
          INPUT_DIR="docs"
          OUTPUT_DIR="docx_output"
          mkdir -p "$OUTPUT_DIR"

          find "$INPUT_DIR" -name "*.md" -type f | while read -r file; do
            REL_PATH="${file#$INPUT_DIR/}"
            REL_DIR=$(dirname "$REL_PATH")
            BASENAME=$(basename "$file" .md)

            mkdir -p "$OUTPUT_DIR/$REL_DIR"
            OUTPUT_FILE="$OUTPUT_DIR/$REL_DIR/$BASENAME.docx"

            echo "Converting: $REL_PATH"
            pandoc "$file" -o "$OUTPUT_FILE" \
              --from markdown \
              --to docx \
              --standalone \
              --reference-doc=custom-reference.docx
          done

      - name: Upload Word documents as artifact
        uses: actions/upload-artifact@v4
        with:
          name: word-documents
          path: docx_output/
          retention-days: 30

      - name: Summary
        run: |
          echo "### Conversion Complete" >> $GITHUB_STEP_SUMMARY
          echo "Converted $(find docx_output -name '*.docx' | wc -l) files" >> $GITHUB_STEP_SUMMARY

How This Workflow Works

Advanced: You can extend this workflow to commit the generated DOCX files back to a separate branch, upload them to a cloud storage bucket (S3, GCS), or send a Slack notification when the conversion completes.

Frequently Asked Questions

How many Markdown files can I batch convert at once?

With Pandoc or Node.js scripts, there is no practical limit. Users routinely convert thousands of files in a single run. The speed depends on file size and system resources, but Pandoc typically processes 50-100 files per second on modern hardware. Online tools are limited to roughly 10-20 files per session due to browser memory constraints.

Will images and tables survive the batch conversion?

Pandoc handles images and tables well when converting to DOCX. Local images referenced with relative paths (e.g., ![alt](./images/photo.png)) are embedded into the Word file. Remote images (URLs) are downloaded and embedded automatically. Tables are converted to native Word tables with proper cell formatting.

Can I convert Markdown to .doc (old Word format) instead of .docx?

Pandoc natively outputs .docx (Office Open XML), not the legacy .doc format. If you need .doc files, you can batch convert the DOCX output using LibreOffice in headless mode: libreoffice --headless --convert-to doc *.docx

How do I handle front matter (YAML) in Markdown files?

Pandoc reads YAML front matter automatically and uses fields like title, author, and date as document metadata in the DOCX output. Add the --standalone flag (already included in our scripts) to ensure metadata is written to the Word file properties.

Is it possible to convert only files that changed since the last run?

Yes. In a Git-based workflow, use git diff --name-only HEAD~1 -- '*.md' to get the list of changed Markdown files, then pass only those to Pandoc. In the GitHub Actions workflow, you can use the tj-actions/changed-files action to detect which files were modified in the current push.

Start Converting Your Markdown Files

Try our free online converter for quick conversions, or use the scripts above for large-scale automation.

Try the Online Converter

100% free - No sign-up - Privacy-first

Related Articles