File Operations Intermediate Updated: 2026-05-03

Build nested folder trees from an Excel hierarchy

Stop right-clicking your way through dozens of New Folder dialogs every time a project starts. Fill columns in Excel as folder depth, and the script creates the whole nested structure via `os.makedirs`. A template-generation mode emits a ready-to-fill Excel with a legend and a worked example, perfect for handing the request off to teammates who do not write Python.

Have you ever been asked to scaffold a brand-new project folder layout and ended up right-clicking → New folder for an hour straight? This script reads an Excel sheet where columns represent folder depth, and creates the full nested structure via `os.makedirs`. A built-in template mode emits a ready-to-fill Excel with examples and a legend, so you can hand the request off to teammates who do not write Python.

What this script can do

  • Bulk-create nested folders by reading Excel columns as depth
  • Generate a ready-to-fill template Excel with a legend and sample data
  • Pre-validate Windows-forbidden characters before any folder is created
  • Skip existing folders, create only the new ones
  • Add columns to extend depth — 5 or 6 levels work the same way
Download folder-structure-builder.pybes

Import the .pybes file into Pybes and the script — along with its config fields — loads automatically.

Config fields

These are the config fields this script uses. Enter values through the Pybes GUI at runtime.

mode Dropdown Required

Mode

Generate template writes a fillable Excel; Create folders reads a filled Excel and builds the tree.

Options: Generate template, Create folders

excel_file File Required

Excel file

Used only in Create folders mode. Pick the .xlsx you previously generated and filled in. Leave empty in template-generation mode.

Extensions: .xlsx

folder Folder Required

Folder

Where the template Excel is saved (mode A) or where folders are created (mode B).

Code walkthrough

import sys
import json
import os
import re
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter

with open(sys.argv[1], encoding="utf-8") as f:
    inputs = json.load(f)

mode = inputs["mode"]
folder = inputs["folder"]
excel_path = inputs["excel_file"]

INVALID_CHARS = r'\/:*?"<>|'
INVALID_PATTERN = re.compile(r'[\\/:*?"<>|]')

def validate_name(name):
    found = set(INVALID_PATTERN.findall(name))
    return sorted(found)

try:
    # ── Generate template ───────────────────────────
    if mode == "Generate template":
        wb = Workbook()

        ws_data = wb.active
        ws_data.title = "Data"

        # Header row (4 columns by default; add more columns to support deeper trees)
        DEFAULT_LEVELS = 4
        header_fill = PatternFill(fill_type="solid", fgColor="4472C4")
        header_font = Font(bold=True, color="FFFFFF")
        for col_i in range(1, DEFAULT_LEVELS + 1):
            cell = ws_data.cell(row=1, column=col_i, value=f"Depth {col_i}")
            cell.font = header_font
            cell.fill = header_fill
            cell.alignment = Alignment(horizontal="center")
            ws_data.column_dimensions[get_column_letter(col_i)].width = 20

        # Legend sheet
        ws_legend = wb.create_sheet(title="Legend")

        section_font = Font(bold=True, color="FFFFFF")
        section_fill = PatternFill(fill_type="solid", fgColor="4472C4")
        sample_fill = PatternFill(fill_type="solid", fgColor="E9F0FB")
        indent_fill = PatternFill(fill_type="solid", fgColor="F5F5F5")

        ws_legend["A1"] = "📋 Folder builder — how to fill this in"
        ws_legend["A1"].font = Font(bold=True, size=13)
        ws_legend.merge_cells("A1:D1")

        ws_legend["A3"] = "Rules"
        ws_legend["A3"].font = section_font
        ws_legend["A3"].fill = section_fill
        ws_legend.merge_cells("A3:D3")

        rules = [
            "- Column A (Depth 1) is the top folder; Column B (Depth 2) sits inside it; Column C and beyond continue the pattern",
            "- The leftmost filled column on a row determines that folder's depth",
            "- Add more columns to support deeper trees (Depth 5, Depth 6, and so on)",
            "- Blank rows are skipped",
            f"- Forbidden characters in folder names: {INVALID_CHARS}",
        ]
        for i, rule in enumerate(rules, start=4):
            ws_legend[f"A{i}"] = rule
            ws_legend.merge_cells(f"A{i}:D{i}")

        ws_legend["A10"] = "Sample"
        ws_legend["A10"].font = section_font
        ws_legend["A10"].fill = section_fill
        ws_legend.merge_cells("A10:D10")

        for col_i, h in enumerate(["Depth 1", "Depth 2", "Depth 3", "Depth 4"], start=1):
            cell = ws_legend.cell(row=11, column=col_i, value=h)
            cell.font = Font(bold=True)
            cell.fill = PatternFill(fill_type="solid", fgColor="BDD7EE")
            cell.alignment = Alignment(horizontal="center")

        sample_rows = [
            ["Project A", "", "", ""],
            ["", "Materials", "", ""],
            ["", "", "FY2024", ""],
            ["", "", "FY2025", ""],
            ["", "Minutes", "", ""],
            ["", "", "Weekly meeting", ""],
            ["Project B", "", "", ""],
            ["", "Materials", "", ""],
            ["", "Reports", "", ""],
        ]
        for row_i, row_data in enumerate(sample_rows, start=12):
            for col_i, val in enumerate(row_data, start=1):
                cell = ws_legend.cell(row=row_i, column=col_i, value=val)
                cell.fill = sample_fill if val else indent_fill

        ws_legend["A22"] = "↑ This example creates: Project A/Materials/FY2024, Project A/Materials/FY2025, Project A/Minutes/Weekly meeting, Project B/Materials, Project B/Reports"
        ws_legend["A22"].font = Font(color="808080", italic=True)
        ws_legend.merge_cells("A22:D22")

        for col in ["A", "B", "C", "D"]:
            ws_legend.column_dimensions[col].width = 22

        save_path = os.path.join(folder, "folder_template.xlsx")
        wb.save(save_path)
        print(f"Template written: {save_path}")

    # ── Create folders ──────────────────────────────
    elif mode == "Create folders":
        if not excel_path:
            print("Error: please specify the Excel file", file=sys.stderr)
            sys.exit(1)

        wb = load_workbook(excel_path, data_only=True)
        ws = wb["Data"]

        max_col = ws.max_column
        print(f"Detected columns: {max_col} (up to {max_col} levels deep)\n")

        # Read from row 2 (row 1 is the header)
        all_rows = []
        for row in ws.iter_rows(min_row=2, values_only=True):
            all_rows.append([str(c).strip() if c is not None else "" for c in row])

        # Validate
        errors = []
        for row_i, row in enumerate(all_rows, start=2):
            for col_i, val in enumerate(row):
                if val:
                    bad = validate_name(val)
                    if bad:
                        errors.append(f"Row {row_i}, Col {col_i+1} '{val}' contains forbidden characters: {' '.join(bad)}")

        if errors:
            print("⚠️ Issues found. Fix the Excel and rerun.\n", file=sys.stderr)
            for e in errors:
                print(f"  - {e}", file=sys.stderr)
            sys.exit(1)

        print("✅ Validation OK\n")

        # Walk depths and build paths
        current_path = {}
        paths_to_create = []

        for row in all_rows:
            level = None
            value = None
            for col_i, val in enumerate(row):
                if val:
                    level = col_i
                    value = val
                    break

            if level is None:
                continue

            current_path[level] = value
            for deeper in list(current_path.keys()):
                if deeper > level:
                    del current_path[deeper]

            parts = [current_path[l] for l in sorted(current_path.keys())]
            full_path = os.path.join(folder, *parts)
            paths_to_create.append((full_path, "/".join(parts)))

        print(f"Detected {len(paths_to_create)} folders\n")

        created = 0
        skipped = 0

        for full_path, display in paths_to_create:
            if not os.path.exists(full_path):
                os.makedirs(full_path)
                print(f"Created: {display}/")
                created += 1
            else:
                print(f"Skipped (existing): {display}/")
                skipped += 1

        print(f"\nDone — created: {created} / skipped: {skipped}")
        print(f"Saved under: {folder}")

except Exception as e:
    print(f"Error: {e}", file=sys.stderr)
L1–14

Loads standard libraries (sys / json / os / re) plus three openpyxl pieces — Workbook and load_workbook for create vs. read, and the styles / utils modules for cell formatting. sys.argv[1] is the JSON config Pybes hands to the script; the script unpacks it into inputs and reads mode, folder, and excel_path. The excel_file field is allow_empty: true, so it arrives as an empty string in template-generation mode.

L16–21

Pre-compiles a regex for the characters Windows forbids in folder names (\\/:*?"<>|) so validate_name can reuse it on every cell. The function returns the offending characters as a sorted, de-duplicated list — a cell with the same forbidden char repeated still produces a clean error message. The plain string INVALID_CHARS is reused twice: once as a rule line in the legend, once when building the error report.

L23–106

Template-generation mode. Workbook() creates a fresh workbook, the default sheet is renamed to Data, and four header cells (Depth 1 ... Depth 4) are placed with white text on a navy fill. wb.create_sheet adds a Legend sheet that carries the rules, a sample structure (Project A / Project B), and a closing note. merge_cells, PatternFill, and Alignment set up a layout that visually communicates how to fill the form — even if the recipient is not the one who will run the script. The output is saved as folder_template.xlsx.

L108–185

Folder-creation mode. load_workbook with data_only=True (so formula cells return cached values) opens the file; every cell is stringified and stripped. Validation walks every cell through validate_name and aborts via sys.exit(1) if anything fails — no folders are created on a partial run. After validation, current_path keeps the currently-open folder name at each depth; for each row the script finds the leftmost non-empty column, prunes deeper levels with del, and builds the path via os.path.join(folder, *parts). os.makedirs finally creates the folders, and existing paths are short-circuited via os.path.exists and counted as skipped.

How it works

Two-mode design (template / create)

A single script bundles two responsibilities — emit a template Excel, or read one and build the folders. A select field switches between if mode == "Generate template": and elif mode == "Create folders":. Typical flow: hand the template to a non-engineer, get it back filled in, and create the structure from the same script.

Excel columns equal folder depth

Column A is depth 1 (project name), column B is depth 2, and so on. The leftmost non-empty column on a row determines that folder's depth. The script keeps a current_path dict that holds the currently-open folder name at each level; when a row jumps to a shallower depth, deeper levels are pruned with del before the new path is built.

How the template Excel is composed

openpyxl builds two sheets from a fresh Workbook() — a Data sheet (four-column header) and a Legend sheet that carries the rules and a sample. Header coloring, cell merging via merge_cells, and grayed-out indent cells in the sample table are all set up so the recipient knows exactly how to fill it in.

Pre-validation aborts before any folder is created

Windows forbids \\/:*?"<>| in folder names, so the script compiles INVALID_PATTERN once and checks every cell up front. If any name violates the rule, the script exits without creating a single folder — avoiding the half-applied state where some folders exist and others do not.

Customization

Change the default depth of the template

DEFAULT_LEVELS = 4 controls how many header columns the template generates (e.g. DEFAULT_LEVELS = 6). The reading side uses ws.max_column to auto-detect width, so editing only the template side is safe.

Swap the sample folder structure in the legend

Rewrite the sample_rows list to match your team's standard layout. Replacing the meeting/material/report rows with design/implementation/test rows turns the template into a dev-project starter.

Drop a blank README into every created folder

Right after os.makedirs(full_path), add open(os.path.join(full_path, "README.md"), "w").close(). The same trick works for .gitkeep files when you intend to commit empty folders to Git.

Loosen or extend the forbidden-character set

Edit INVALID_CHARS and the INVALID_PATTERN regex. To allow : for Mac/Linux deployments, drop it from the pattern; to also forbid full-width space, use r'[\\\\/:*?"<>| ]'. One change, both validation messages and detection follow.

Troubleshooting

FileNotFoundError: Excel file not found

If you pick Create folders mode but leave Excel file empty (or the path is wrong), the script exits with this error. Use the Pybes file picker to select an .xlsx file. The picker filters by extension, so non-Excel files are not selectable.

PermissionError WinError 5: access is denied

Choosing a write target inside an admin-protected location (e.g. under C:\\Program Files) fails without elevation. Pick a folder under Documents or Desktop — somewhere in your user profile. OneDrive-synced folders can occasionally lock too.

KeyError: Data sheet not found

If you renamed the template's Data sheet, wb["Data"] raises KeyError. Keep the sheet name and only edit rows. To rename it, also update the ws = wb["Data"] literal in code.

Stops at Issues found before creating anything

Any of \\/:*?"<>| in a folder name aborts the run, since Windows cannot create those. Check whether you accidentally typed a hierarchy like 2024/Q1 into a single cell — split into separate columns instead.

FAQ

Why columns instead of rows for the hierarchy?

Columns are the most natural way to express nesting in Excel. A row-based representation would need separate parent/child/grandchild columns and repeat the parent name on every row. With column-as-depth, each parent is written once and the structure stays readable at a glance.

What happens if I skip levels (A blank, B blank, only C filled)?

The leftmost non-empty column wins, so a C-only row is treated as depth 3 inside the currently-open depth 2 folder. The script remembers the open levels in a current_path dict, so you do not need to repeat A and B on every child row.

Does it touch the contents of existing folders?

No. os.path.exists is checked first; existing paths log as Skipped (existing) and are otherwise untouched. Files and subfolders inside them remain intact, so it is safe to rerun against an in-progress project.

Will it handle a 10,000-row Excel?

Yes, but the runtime scales with how many os.makedirs calls happen. The pre-validation scan is fast (in-memory). Disk I/O dominates — a few thousand folders per minute on SSD is typical. For huge hierarchies, sanity-check on a slice first.

See more common questions →
Download folder-structure-builder.pybes

Import the .pybes file into Pybes and the script — along with its config fields — loads automatically.