DEV Community

Cover image for Nim + FastAPI experiment
AranaDeDoros
AranaDeDoros

Posted on • Edited on

Nim + FastAPI experiment

Why use one language when you can use two?

I've been experimenting with FastAPI and Nim lately, and I decided to bridge them together to build a simple PDF merger.

The goal? Offload the possible heavy binary lifting of PDF generation to Nim (which compiles to C) while keeping the web layer comfy with Python.

Nim process:

import nimpdf
import nimpdf/image
import os
import times

proc makePDF(outputPath: string, files: seq[string]): bool =
  let factorX = 20.0
  let factorY = 200.0
  var pdf = newPDF()

  for file in files:
    block:
      discard pdf.addPage("A4")
      let img = loadImage(file)

      if img == nil:
        echo "Warning: Could not load ", file
        continue

      pdf.drawImage(factorX, factorY, img)

  return pdf.writePDF(outputPath)

when isMainModule:
  let start = cpuTime()
  if paramCount() < 2:
    quit "Usage: pdf_merger <output.pdf> <images...>", QuitFailure

  let outputPath = paramStr(1)
  var images: seq[string] = @[]
  for i in 2 .. paramCount():
    images.add paramStr(i)

  if makePDF(outputPath, images):
    let duration = cpuTime() - start
    echo "PDF created in ", duration, " seconds at ", outputPath
    quit QuitSuccess
  else:
      quit "Failed to write PDF", QuitFailure

Enter fullscreen mode Exit fullscreen mode

The endpoint:

@app.post("/upload-multiple-files/")
async def create_upload_files(files: List[UploadFile] = File(...)):
    start = time.perf_counter()
    job_id = str(uuid.uuid4())
    output_filename = os.path.join(OUTPUT_DIR, f"{job_id}.pdf")

    uploaded_paths = []
    for file in files:
        unique_img_name = f"{uuid.uuid4()}_{file.filename}"
        file_path = os.path.join(UPLOAD_DIR, unique_img_name)
        with open(file_path, "wb") as buffer:
            shutil.copyfileobj(file.file, buffer)
        uploaded_paths.append(file_path)

    def run_nim():
        return subprocess.run(
            [NIM_EXE_PATH, output_filename] + uploaded_paths,
            capture_output=True,
            text=True
        )

    result = await anyio.to_thread.run_sync(run_nim)

    if result.returncode == 0:
        end = time.perf_counter()
        duration = end - start
        return FileResponse(
            path=output_filename,
            filename="merged_images.pdf",
            media_type="application/pdf",
            headers={"X-Duration-Seconds": str(duration)}
        )
    else:
        return {"status": "error", "error": result.stderr}
Enter fullscreen mode Exit fullscreen mode

Result:

The "Dirty" Details:

The Bridge: FastAPI handles the multi-part file uploads, saves them, and then kicks off a Nim subprocess to "plaster" the images onto an A4 page.

The Windows Trap: If you've ever tried spawning subprocesses in asyncio on Windows, you've likely met the NotImplementedError. I bypassed this using anyio.to_thread. It's a "sync-in-async" hack that keeps the server non-blocking.

Performance: nimpdf is impressively lightweight. It makes the "binary crunching" part of the stack feel almost instant.

Why do this? Mostly curiosity. I wanted to see how much friction there was in passing data into a systems-level binary from a high-level web framework. Turns out, once you get the subprocess logic right, it's a pretty powerful pattern.

Note: From what I've read, while the subprocess approach worked, a more "pro" move would be to compile Nim to a .so file.

Feature Subprocess Shared Library (.so/FFI)
Setup Complexity Low: Just call a CLI command. High: Requires C-bindings and type mapping.
Performance Medium: Overhead for process startup. Maximum: Native speed, zero startup lag.
Stability High: If Nim crashes, Python lives. Risky: If Nim crashes, the whole server dies.
Memory Isolated: Separate memory space. Shared: Very efficient but easy to leak.
Best For Quick tools and background tasks. High-frequency, real-time operations.

Instead of Python calling a program, Python becomes the program, executing Nim code natively via C-bindings. This removes the OS overhead of spawning processes and keeps everything in-memory.

Take a peek. Beware, it's not validated, this was just a proof of concept.

Top comments (0)