scandir.py: a fast, greppable file index instead of Windows search

Let’s be honest: Windows search sucks 😉 You type a file name you know exists, watch the green bar crawl through the Explorer window, and end up with either nothing, the wrong thing, or a web search suggestion you never asked for. The search index covers some folders but not others, it silently skips network drives and external disks, and it has its own idea of what is relevant. When all you want is “where on earth did I put budget_forecast_2019.xlsx ?”, that is just frustrating.

So I went back to an old-school approach: create a plain text file that lists every file of a directory tree once, together with its date and size, and then search that file with grep. Searching a text file with a million lines takes well under a second, works with regular expressions, can be combined with other command-line tools, and gives you exactly what you asked for, nothing more.

Years ago I simply used dir /s > all.txt for that. It works, but the output splits path and file name across lines (so a grep hit on a file name does not tell you where the file is), the format depends on the system language, and special characters in file names turn into codepage garbage. Hence the small Python tool below.

What scandir.py does

scandir.py walks a directory tree and writes one line per file and folder into a text file (default: all.txt). Each line contains the modification date, the size (or <DIR> for folders) and the full path, so every line is self-contained and can be grepped on its own.

  • Fast: it uses os.scandir(), which on Windows gets size and date directly from the directory listing without extra disk I/O. On my machine it indexes about 1.1 million files in 44 seconds.
  • Same order as dir /s: entries are sorted case-insensitively, folders and files mixed, and the tree is processed depth-first, with a blank line after each directory listing.
  • Robust: output is UTF-8; odd file names are written with replacement characters instead of crashing, and folders without access permission are skipped silently.
  • Progress bar: it counts the folders on the first two levels beforehand and shows a progress bar on stderr while scanning.
  • No dependencies: it needs only the Python standard library; runs on Windows, Linux and macOS.

Usage:

python scandir.py                    # index the current folder into all.txt
python scandir.py C:\Users\wilfried  # index a specific folder
python scandir.py D:\ -o d_drive.txt # index a whole drive into d_drive.txt

Run it every now and then (e.g., via the Windows Task Scheduler) to keep the index reasonably current. Since it is just a snapshot, files created after the last run will of course not show up.

Source code

# This script creates a searchable textfile of all files in the file tree.
# The output filename default is all.txt, but it can be changed with the -o option
#
# Takes 44 seconds to index 1.1 million files
#
# 2026 Wil

import os
import sys
import time

ROOT = "."
OUTFILE = "all.txt"
BLOCK_SIZE = 100_000  # lines per write

# Cache formatted timestamps per minute (many files share the same minute)
_date_cache = {}
_strftime = time.strftime
_localtime = time.localtime


def fmt_date(mtime):
    key = int(mtime) // 60
    s = _date_cache.get(key)
    if s is None:
        s = _strftime("%d.%m.%Y  %H:%M", _localtime(key * 60))
        if len(_date_cache) > 200_000:
            _date_cache.clear()
        _date_cache[key] = s
    return s


def sort_key(entry):
    return entry.name.upper()


def count_top_dirs(root):
    """Count the folders on levels 1 and 2 below root (used as progress steps)."""
    total = 0
    try:
        with os.scandir(root) as it:
            level1 = [e.path for e in it if e.is_dir(follow_symlinks=False)]
    except OSError:
        return 0
    total += len(level1)
    for d in level1:
        try:
            with os.scandir(d) as it:
                total += sum(1 for e in it if e.is_dir(follow_symlinks=False))
        except OSError:
            pass
    return total


BAR_W = 40


def show_progress(done, total, state):
    # Redraws only when the whole-percent value changes: at most 101 writes per run
    pct = 100 if total <= 0 else min(100, done * 100 // total)
    if pct != state[0]:
        state[0] = pct
        filled = BAR_W * pct // 100
        sys.stderr.write(f"\r[{'â–ˆ' * filled}{'â–‘' * (BAR_W - filled)}] {pct:3d}%")
        sys.stderr.flush()


def main():
    root = ROOT
    outfile = OUTFILE
    args = sys.argv[1:]
    i = 0
    while i < len(args):
        arg = args[i]
        low = arg.lower()
        if low in ("-o", "--o", "-out", "--out"):
            i += 1
            if i >= len(args):
                sys.exit("missing file name after " + arg)
            outfile = args[i]
        else:
            root = arg
        i += 1
    root = os.path.abspath(root)
    try:
        sys.stderr.reconfigure(encoding="utf-8", errors="replace")  # bar chars on any console codepage
    except (AttributeError, ValueError):
        pass
    total = count_top_dirs(root)
    done = 0
    pstate = [-1]
    show_progress(0, total, pstate)
    stack = [(root, 0)]
    buf = []
    append = buf.append
    scandir = os.scandir

    # utf-8 + errors="replace": no codepage garbage, no crash on odd file names
    with open(outfile, "w", encoding="utf-8", errors="replace", newline="\n") as f:
        write = f.write
        while stack:
            current, depth = stack.pop()
            if 0 < depth <= 2:
                done += 1
                show_progress(done, total, pstate)
            try:
                it = scandir(current)
            except OSError:
                continue
            with it:
                entries = list(it)
            # Same order as `dir /s`: case-insensitive by name, folders and files mixed
            entries.sort(key=sort_key)
            subdirs = []
            start = len(buf)
            for entry in entries:
                try:
                    # On Windows, stat() is served from the directory listing: no extra I/O
                    if entry.is_dir(follow_symlinks=False):
                        st = entry.stat(follow_symlinks=False)
                        append(f"{fmt_date(st.st_mtime)}\t<DIR>\t{entry.path}")
                        subdirs.append((entry.path, depth + 1))
                    else:
                        st = entry.stat(follow_symlinks=False)
                        append(f"{fmt_date(st.st_mtime)}\t{st.st_size}\t{entry.path}")
                except OSError:
                    continue
            if len(buf) > start:
                append("")  # blank line after each directory's listing, like dir /s
            # reversed, so the stack pops subfolders in sorted order (depth-first like dir /s)
            stack.extend(reversed(subdirs))
            if len(buf) >= BLOCK_SIZE:
                write("\n".join(buf) + "\n")
                buf.clear()
        if buf:
            write("\n".join(buf) + "\n")
    show_progress(total, total, pstate)
    sys.stderr.write("\n")


if __name__ == "__main__":
    main()

Output format

Here is an excerpt of an index generated for a sample directory:

14.03.2018  09:14	<DIR>	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12
02.11.2021  14:52	<DIR>	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\infrastructure_70
02.11.2021  14:52	<DIR>	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\q3_reports_29
19.07.2016  22:05	4388	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\scandir.py
05.05.2023  08:33	1455569	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\strategy_draft_271.logx

28.09.2019  17:41	<DIR>	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12\architecture_41
28.09.2019  17:41	4520241	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12\budget_forecast_791.cfgx
11.01.2015  11:20	<DIR>	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12\client_assets_51
11.01.2015  11:20	4452759	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12\client_feedback_415.cfgx
30.06.2025  03:12	2897609	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12\employee_matrix_527.secx
30.06.2025  03:12	<DIR>	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12\financials_18
12.12.2017  19:50	7358195	C:\Users\exampleuser\Documents\webpage\tech.nes.aau.at\sampledir\architecture_12\network_topology_537.arch

Each line has three fields separated by a tab character, date/time, size in bytes (or <DIR> for a folder) and the filename with the full path.

Searching with grep

On Linux and macOS, grep is already there; on Windows you get it with Git for Windows (Git Bash), WSL, or a native port. Some examples:

Find all files with a certain name part (case-insensitive):

grep -i "budget_forecast" all.txt
28.09.2019  17:41	4520241	C:\...\sampledir\architecture_12\budget_forecast_791.cfgx
12.12.2017  19:50	1393438	C:\...\sampledir\architecture_12\architecture_41\budget_forecast_744.arch
17.10.2024  16:29	6027244	C:\...\sampledir\architecture_12\client_assets_51\budget_forecast_971.secx

Find all files with a given extension (the $ anchors the match at the end of the line):

grep -i "\.cfgx$" all.txt

Find only folders whose name contains research_data:

grep "<DIR>" all.txt | grep -i "research_data"

Find everything modified in 2026:

grep -E "^[0-9]{2}\.[0-9]{2}\.2026" all.txt

Find files larger than 7 MB (here awk uses the tab-separated size column):

awk -F'\t' '$2 ~ /^[0-9]+$/ && $2 > 7000000' all.txt

Show only the paths of the hits, without date and size:

grep -i "seminar" all.txt | cut -f3

If you don’t have grep at hand, Windows’ built-in findstr also does the job for simple searches: findstr /i "budget_forecast" all.txt. But given the fact that grep is a much more powerful tool, I wamly recommend adding it to your system.

Happy searching!

Merge Password-Protected PDF files

Sometimes, PDF files—like those from book chapters—come password-protected to prevent editing, which also stops you from merging them without knowing the password. If printing is allowed, you can work around this by printing the file to PDF, but this creates a new PDF where the text is no longer selectable or searchable. While you could re-OCR the file, a much better solution is to use an old yet powerful tool: Ghostscript, originally developed by Adobe.

Ghostscript allows you to manipulate PDF files easily, including merging multiple PDFs into one. For example, to merge three PDFs named 1.pdf, 2.pdf, and 3.pdf, you can run the following command:

gs -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite -sOutputFile=merged.pdf 1.pdf 2.pdf 3.pdf

Here’s what the parameters mean:-dBATCH: Tells Ghostscript to process all files and then exit (batch mode).-dNOPAUSE: Disables pauses between processing each page (no manual intervention required).-q: Runs the command quietly, suppressing most of the output messages.-sDEVICE=pdfwrite: Specifies that the output should be written as a PDF.-sOutputFile=merged.pdf: Defines the name of the resulting merged file.

If you have many files that are consecutively named (e.g., 1.pdf to 100.pdf), you can generate the file list dynamically in the command line, like this:

gs -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite -sOutputFile=merged.pdf $(seq -f "%g.pdf" 1 100)

Note that the gs tool takes considerable time to handle so many entries, but this approach saves your time after all.

Windows .bat file command line tool, wait for keypress when double-clicked

Windows .bat files can be a convenient and quick way to automate things, like, for example, a compilation command involving a lot of options. They can be executed from the command line or by double-clicking them from the explorer. In the latter case, one would want the script to wait for a keypress until they close the window. When started from the command line, the waiting from a keypress is rather annoying.

It is possible to make the script behave differently depending on the way it was executed:

IF /I %0 EQU "%~dpnx0" PAUSE

This executes the PAUSE command when the file was started by double-click.

Source: Detect if bat file is running via double click or from cmd window

Starting a local Webserver for Testing

Easiest way is to open a console window in what you would like to have as root directory of your webserver and start

python -m http.server

Then, by serving to http://127.0.0.1:8000 you will be able to view and browse the contents.

To specify a port, just add the port number to the command, for example

python -m http.server 9999

Please not that this webserver does not support https, so you have to enter the URL with a starting http:// and you might have to click away a browser warning because of the unsecured site.

Therefore, only use this for local testing!

Making Screenshots under Windows

To copy a screenshot of the active window into the clipboard: Alt + PrntScrn (Alt + Druck on a German keyboard) Note that the clipboard will be overwritten, so if you want to take two screenshots, you have to paste the first screenshot before taking the second one.

To copy the whole screen into the clipboard: PrntScrn (Druck on a German keyboard)

To make a screenshot with customized clipping, press Winkey + Shift + S.

To save a screenshot of the active window: Alt+ Winkey + PrntScrn (Alt + WinKey + Druck on a German keyboard) The image will be saved in PNG format into the folder Videos\Captures (Videos\Aufzeichnungen on a German Windows)

To save a screenshot of the whole screen: Winkey + PrntScrn (WinKey + Druck on a German keyboard) The image will be saved in PNG format into the folder Pictures\Screenshots (Bilder\Bildschirmfotos on a German Windows)

While it is crazy that two different folders are used for saving the images, other than that the shortkeys are a convenient method to quickly obtain and save screenshots.

View unreachable web pages

There can be several reasons why a webpage is not available. Apart from the obvious server outage, there could be a geo block or a recent deletion of the page.

As long as you are able to get the address of the webpage there is a good chance to retrieve it.

One possibility is to use https://cachedview.com/ which allows to view cached pages from Google web. It is usually updated every few days by google so as long as an article is a couple of days old, you have a good chance accessing it there.

Another option is https://web.archive.org/ which archives copies of webpages including old versions. So if you want to know what was on a certain webpage years ago, archive.org is a good start.