← Back to DevBytes

Haskell for System Programming: Step-by-Step Guide to

Haskell for System Programming: A Step-by-Step Guide

Haskell is often pigeonholed as an academic language or a tool for writing compilers and financial software. Yet its strong static typing, purity, and high-level abstractions make it an excellent choice for system programming tasks that demand correctness, maintainability, and performance. This guide walks you through using Haskell for real system-level work—file I/O, process management, networking, and foreign function interfacing—with complete, runnable code examples.

What Is Haskell System Programming?

System programming in Haskell means leveraging the language's advanced type system and runtime to interact directly with operating system resources. This includes:

Haskell's purity guarantees that side effects (like mutating global state or performing I/O) are explicitly tracked in the type system using the IO monad. This gives you a level of safety and reasoning power that is unmatched by traditional system languages like C or Python.

Why It Matters

Choosing Haskell for system tasks offers several compelling advantages:

Getting Started: Environment Setup

Before diving into system programming, ensure you have a working Haskell environment:

# Install GHCup (recommended toolchain manager)
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh

# After installation, ensure you have:
ghcup install ghc 9.4.7
ghcup install cabal 3.10.2.0

# Verify your setup
ghc --version
cabal --version

Create a new project for your system tool:

cabal init --name sysprog --simple --main-is Main
cd sysprog
# Edit sysprog.cabal to add dependencies (shown below)

Your sysprog.cabal should include at least these dependencies for system programming:

executable sysprog
  main-is: Main.hs
  build-depends:
      base >= 4.16 && < 5,
      bytestring >= 0.11,
      process >= 1.6,
      unix >= 2.8,
      directory >= 1.3,
      filepath >= 1.4
  default-language: Haskell2010
  ghc-options: -Wall -O2

Core System Programming Operations

1. File I/O with Strict Control

System programming often requires reading and writing files with precise control over buffering, file descriptors, and error handling. Haskell provides multiple layers of abstraction—from high-level lazy I/O to low-level POSIX operations.

Here is a complete example that reads a file in strict chunks, processes each chunk, and writes the result to an output file, handling errors properly:

import Control.Exception (try, catch, IOException)
import System.IO
    ( IOMode(ReadMode, WriteMode)
    , openFile, hClose, hIsEOF, hGetBufSome
    , hPutBuf, withFile
    )
import Foreign.Marshal.Alloc (allocaBytes, free)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Unsafe as BSU
import Foreign.Ptr (Ptr)
import Foreign.C.Types (CSize)

-- | Process a chunk of bytes (example: invert all bytes)
processChunk :: Ptr Word8 -> Int -> IO Int
processChunk ptr size = do
    let loop i
            | i >= size = return i
            | otherwise = do
                -- Read byte from buffer at offset i
                b <- peekByteOff ptr i
                -- Invert byte and write back
                pokeByteOff ptr i (complement b)
                loop (i + 1)
    loop 0

-- | Read a file in 64KB chunks, transform each chunk, write output
transformFile :: FilePath -> FilePath -> IO ()
transformFile inputPath outputPath = do
    -- Open input file for reading
    inputHandle <- openFile inputPath ReadMode
    -- Open output file for writing
    outputHandle <- openFile outputPath WriteMode
    
    let bufferSize = 65536  -- 64 KB
    let go = do
            eof <- hIsEOF inputHandle
            if eof
                then return ()
                else allocaBytes bufferSize $ \ptr -> do
                    -- Read up to bufferSize bytes into allocated buffer
                    bytesRead <- hGetBufSome inputHandle ptr bufferSize
                    if bytesRead == 0
                        then return ()
                        else do
                            -- Process the chunk in-place
                            processChunk ptr bytesRead
                            -- Write processed bytes to output
                            hPutBuf outputHandle ptr bytesRead
                            go
    
    -- Execute the loop with cleanup
    go `finally` do
        hClose inputHandle
        hClose outputHandle
    putStrLn $ "File transformed: " ++ outputPath

main :: IO ()
main = transformFile "input.bin" "output.bin"

This example demonstrates manual buffer allocation, raw pointer manipulation, and deterministic cleanup using finally. For simpler cases, the ByteString library provides high-performance I/O without manual memory management:

import qualified Data.ByteString as BS

-- Simple but efficient file copy using ByteString
copyFileBS :: FilePath -> FilePath -> IO ()
copyFileBS src dst = do
    content <- BS.readFile src
    BS.writeFile dst content

2. Process Management and Execution

Spawning and controlling external processes is a cornerstone of system programming. Haskell's System.Process module offers a rich API that goes far beyond simple system() calls. Here's how to run a process, stream its output, handle timeouts, and capture both stdout and stderr:

import System.Process
    ( createProcess, proc, shell, CreateProcess(..)
    , StdStream(..), waitForProcess, terminateProcess
    )
import System.Exit (ExitCode(..))
import System.IO
    ( hGetContents, hPutStr, hClose, BufferMode(NoBuffering)
    , hSetBuffering
    )
import Control.Concurrent (threadDelay, forkIO, killThread)
import Control.Exception (try, IOException)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS

-- | Run a command with a timeout (in seconds)
runCommandWithTimeout :: Int -> String -> IO (Either String (ExitCode, String))
runCommandWithTimeout timeoutSecs cmd = do
    -- Create the process, capturing stdout and stderr as separate pipes
    (Just stdinH, Just stdoutH, Just stderrH, processHandle) <-
        createProcess (proc "sh" ["-c", cmd])
            { std_in = CreatePipe
            , std_out = CreatePipe
            , std_err = CreatePipe
            }
    
    -- Close stdin immediately (no input)
    hClose stdinH
    
    -- Set no buffering for timely output
    hSetBuffering stdoutH NoBuffering
    hSetBuffering stderrH NoBuffering
    
    -- Fork a timeout thread
    tid <- forkIO $ do
        threadDelay (timeoutSecs * 1000000)
        terminateProcess processHandle
    
    -- Wait for the process to finish
    exitCode <- waitForProcess processHandle
    killThread tid  -- Cancel timeout if process finished
    
    -- Read captured output
    stdoutContent <- hGetContents stdoutH
    stderrContent <- hGetContents stderrH
    
    return $ Right (exitCode, stdoutContent ++ "\n" ++ stderrContent)
        `catch` \(e :: IOException) ->
            return $ Left ("Process error: " ++ show e)

-- Example usage
main :: IO ()
main = do
    result <- runCommandWithTimeout 5 "ls -la /tmp | head -5"
    case result of
        Left err -> putStrLn $ "Error: " ++ err
        Right (ExitSuccess, output) -> putStrLn output
        Right (ExitFailure n, output) ->
            putStrLn $ "Failed with code " ++ show n ++ "\n" ++ output

For streaming large outputs without buffering the entire result in memory, use incremental reading:

import System.Process
import System.IO
import Control.Monad (when)

-- | Stream command output line by line to a callback
streamCommand :: String -> (String -> IO ()) -> IO ExitCode
streamCommand cmd callback = do
    (_, Just stdoutH, _, processHandle) <-
        createProcess (proc "sh" ["-c", cmd])
            { std_out = CreatePipe }
    
    -- Read lines incrementally and invoke callback
    let loop = do
            eof <- hIsEOF stdoutH
            when (not eof) $ do
                line <- hGetLine stdoutH
                callback line
                loop
    
    loop `finally` hClose stdoutH
    exitCode <- waitForProcess processHandle
    return exitCode

main :: IO ()
main = do
    exitCode <- streamCommand "find /usr -name '*.conf'" print
    putStrLn $ "Command exited with: " ++ show exitCode

3. Network Programming with Sockets

Haskell provides low-level POSIX socket operations through the Network.Socket module. Here is a complete TCP echo server that demonstrates socket creation, binding, listening, accepting connections, and non-blocking I/O patterns:

import Network.Socket
    ( Socket, Family(AF_INET), SocketType(Stream)
    , defaultProtocol, SockAddr(SockAddrInet)
    , socket, bind, listen, accept, connect
    , close, setSocketOption
    , SocketOption(ReuseAddr)
    )
import Network.Socket.ByteString (recv, sendAll)
import qualified Data.ByteString as BS
import Control.Concurrent (forkIO)
import Control.Exception (catch, IOException)
import System.IO (hPutStrLn, stderr)
import Data.Foldable (forM_)

-- | Handle a single client connection
handleClient :: Socket -> SockAddr -> IO ()
handleClient sock clientAddr = do
    putStrLn $ "Client connected from: " ++ show clientAddr
    let loop = do
            -- Receive up to 4096 bytes
            msg <- recv sock 4096
            if BS.null msg
                then putStrLn $ "Client disconnected: " ++ show clientAddr
                else do
                    -- Echo back with prefix
                    let response = BS.append (BS.pack "ECHO: ") msg
                    sendAll sock response
                    loop
    loop `catch` \(e :: IOException) -> do
        hPutStrLn stderr $ "Client error: " ++ show e
    close sock

-- | Start a TCP echo server on the given port
echoServer :: Int -> IO ()
echoServer port = do
    -- Create a TCP socket
    serverSocket <- socket AF_INET Stream defaultProtocol
    
    -- Allow reusing the address (important for quick restarts)
    setSocketOption serverSocket ReuseAddr 1
    
    -- Bind to all interfaces on the specified port
    bind serverSocket (SockAddrInet (fromIntegral port) 0)
    
    -- Start listening (max 1024 backlog)
    listen serverSocket 1024
    
    putStrLn $ "Echo server listening on port " ++ show port
    
    -- Accept loop
    let acceptLoop = do
            (clientSock, clientAddr) <- accept serverSocket
            -- Fork a new thread for each client
            forkIO $ handleClient clientSock clientAddr
            acceptLoop
    
    acceptLoop `catch` \(e :: IOException) -> do
        hPutStrLn stderr $ "Server error: " ++ show e
        close serverSocket

main :: IO ()
main = echoServer 9000

For a TCP client that connects to the server, sends data, and receives the echoed response:

import Network.Socket
import Network.Socket.ByteString (recv, sendAll)
import qualified Data.ByteString as BS

tcpClient :: String -> Int -> BS.ByteString -> IO BS.ByteString
tcpClient host port payload = do
    -- Resolve address hints and connect
    let hints = defaultHints { addrSocketType = Stream }
    addrInfo <- getAddrInfo (Just hints) (Just host) (Just $ show port)
    let serverAddr = head addrInfo
    
    sock <- socket (addrFamily serverAddr) (addrSocketType serverAddr)
                   (addrProtocol serverAddr)
    connect sock (addrAddress serverAddr)
    
    -- Send payload
    sendAll sock payload
    
    -- Receive response
    response <- recv sock 4096
    
    close sock
    return response

main :: IO ()
main = do
    response <- tcpClient "127.0.0.1" 9000 (BS.pack "Hello Server!")
    putStrLn $ "Server replied: " ++ show response

4. Working with Directories and File Metadata

System tools often need to traverse directory trees, examine file permissions, and manipulate symbolic links. Here's a recursive directory scanner that collects file metadata using POSIX system calls:

import System.Directory
    ( listDirectory, doesFileExist, doesDirectoryExist
    , getFileSize, getModificationTime, getPermissions
    , pathIsSymbolicLink, canonicalizePath
    )
import System.FilePath (())
import System.Posix.Files
    ( getFileStatus, fileOwner, fileGroup, fileMode
    , accessTime, modificationTime, isRegularFile
    , isDirectory, isSymbolicLink
    )
import Data.Time (UTCTime)
import Data.Time.Format (formatTime, defaultTimeLocale)
import Control.Monad (forM_, when)

data FileEntry = FileEntry
    { entryPath :: FilePath
    , entrySize :: Integer
    , entryModified :: UTCTime
    , entryIsSymlink :: Bool
    , entryIsDir :: Bool
    , entryMode :: String
    } deriving (Show)

-- | Recursively scan a directory and collect metadata
scanDirectory :: FilePath -> IO [FileEntry]
scanDirectory root = do
    absRoot <- canonicalizePath root
    go absRoot
  where
    go path = do
        isDir <- doesDirectoryExist path
        if isDir
            then do
                entries <- listDirectory path
                let paths = map (path ) entries
                -- Recursively scan subdirectories
                results <- concat <$> mapM go paths
                return results
            else do
                isSym <- pathIsSymbolicLink path
                size <- getFileSize path
                mtime <- getModificationTime path
                perms <- getPermissions path
                let modeStr = show perms
                return [FileEntry path size mtime isSym False modeStr]

-- | Pretty-print a file entry
printEntry :: FileEntry -> IO ()
printEntry e = do
    let typeStr
            | entryIsSymlink e = "LNK"
            | entryIsDir e = "DIR"
            | otherwise = "REG"
    let timeStr = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" (entryModified e)
    putStrLn $ typeStr ++ " | " ++ take 12 (show (entrySize e)) ++ " bytes | "
               ++ timeStr ++ " | " ++ entryPath e

main :: IO ()
main = do
    entries <- scanDirectory "/tmp/testdir"
    forM_ entries printEntry
    putStrLn $ "Total entries: " ++ show (length entries)

5. Signal Handling and System Events

Robust system programs must handle POSIX signals gracefully. Haskell allows you to install signal handlers, catch signals, and implement proper shutdown sequences:

import System.Posix.Signals
    ( Signal, installHandler, sigINT, sigTERM, sigHUP
    , Handler(Catch, Ignore, Default), fullSignalSet
    , sigemptyset, sigaddset, sigprocmask, SigSet
    , SignalSet
    )
import Control.Concurrent
    ( MVar, newEmptyMVar, putMVar, takeMVar
    , threadDelay, forkIO
    )
import System.Exit (exitSuccess)

-- | A simple daemon that handles signals gracefully
signalHandlingDaemon :: IO ()
signalHandlingDaemon = do
    -- Create an MVar to signal shutdown
    shutdownFlag <- newEmptyMVar
    
    -- Block signals in all threads, then unblock in the handler thread
    let blockSet = sigaddset (sigemptyset fullSignalSet) sigINT
    sigprocmask sigprocmask blockSet
    
    -- Fork a signal handler thread
    _ <- forkIO $ do
        -- Unblock signals for this thread only
        sigprocmask sigUnblock blockSet
        -- Install handlers that put values into the MVar
        installHandler sigINT (Catch (putMVar shutdownFlag "SIGINT")) Nothing
        installHandler sigTERM (Catch (putMVar shutdownFlag "SIGTERM")) Nothing
        installHandler sigHUP (Catch (putMVar shutdownFlag "SIGHUP")) Nothing
        -- Wait indefinitely (handlers will interrupt)
        threadDelay maxBound
    
    putStrLn "Daemon started. Send SIGINT or SIGTERM to stop."
    
    -- Main work loop
    let workLoop = do
            putStrLn "Daemon: performing work cycle..."
            threadDelay 2000000  -- 2 seconds
            workLoop
    
    -- Race between work and shutdown
    _ <- forkIO workLoop
    
    -- Wait for a signal
    signalName <- takeMVar shutdownFlag
    putStrLn $ "Received " ++ signalName ++ ", shutting down gracefully."
    
    -- Cleanup would go here
    putStrLn "Daemon stopped."
    exitSuccess

main :: IO ()
main = signalHandlingDaemon

6. Foreign Function Interface (FFI) for Low-Level Work

When you need to call C libraries directly—for performance-critical operations or to access OS-specific APIs—Haskell's FFI makes this straightforward and safe. Here's a complete example that calls POSIX getpid, uname, and a custom C function:

First, the C helper file (cbits/syshelpers.c):

/* cbits/syshelpers.c */
#include 
#include 
#include 

/* Get the system hostname (wrapper for uname) */
int get_hostname(char *buffer, int buflen) {
    struct utsname info;
    if (uname(&info) != 0) return -1;
    strncpy(buffer, info.nodename, buflen - 1);
    buffer[buflen - 1] = '\0';
    return 0;
}

/* Simple checksum function */
unsigned char checksum(const unsigned char *data, int len) {
    unsigned char sum = 0;
    for (int i = 0; i < len; i++) {
        sum ^= data[i];
    }
    return sum;
}

Now the Haskell FFI bindings:

{-# LANGUAGE ForeignFunctionInterface #-}

import Foreign.C.Types (CInt(..), CUChar(..), CSize(..))
import Foreign.C.String (CString, peekCString, withCString)
import Foreign.Marshal.Alloc (allocaBytes)
import Foreign.Ptr (Ptr, nullPtr)
import Foreign.Storable (peek)

-- | Get the current process ID via POSIX getpid()
foreign import ccall "unistd.h getpid" c_getpid :: IO CInt

-- | Get system hostname via our C wrapper
foreign import ccall "get_hostname" 
    c_get_hostname :: Ptr CChar -> CInt -> IO CInt

-- | Compute a simple checksum via our C function
foreign import ccall "checksum" 
    c_checksum :: Ptr CUChar -> CInt -> IO CUChar

-- | High-level Haskell wrapper for getpid
getProcessID :: IO Int
getProcessID = do
    pid <- c_getpid
    return (fromIntegral pid)

-- | High-level wrapper for hostname
getSystemHostname :: IO String
getSystemHostname = do
    allocaBytes 256 $ \ptr -> do
        result <- c_get_hostname ptr 256
        if result == 0
            then peekCString ptr
            else return "unknown"

-- | Compute checksum of a ByteString using C
computeChecksum :: ByteString -> IO Word8
computeChecksum bs =
    BSU.unsafeUseAsCStringLen bs $ \(ptr, len) -> do
        sum <- c_checksum (castPtr ptr) (fromIntegral len)
        return (fromIntegral sum)

main :: IO ()
main = do
    pid <- getProcessID
    hostname <- getSystemHostname
    
    putStrLn $ "Process ID: " ++ show pid
    putStrLn $ "Hostname: " ++ hostname
    
    let data = BS.pack [1, 2, 3, 4, 5, 6, 7, 8]
    chk <- computeChecksum data
    putStrLn $ "Checksum: " ++ show chk

To compile this with C sources, add to your sysprog.cabal:

  c-sources: cbits/syshelpers.c
  extra-libraries: 
  include-dirs: cbits

7. Memory-Mapped Files for High Performance

For the ultimate in file I/O performance, memory-mapped files allow direct access to file contents as if they were in memory. Haskell's bytestring-mmap package provides this capability. Here's a word-counting example using mmap:

import System.IO.MMap
    ( mmapFileForeignPtr, Mode(ReadOnly), rangeToPtr
    , nullForeignPtr
    )
import Foreign.ForeignPtr (withForeignPtr)
import Foreign.Ptr (Ptr, plusPtr)
import Foreign.Storable (peek)
import Data.Word (Word8)
import Control.Exception (bracket)

-- | Count occurrences of a byte sequence in a file using mmap
countPattern :: FilePath -> ByteString -> IO Int
countPattern filePath pattern = do
    let patBytes = BS.unpack pattern
        patLen = length patBytes
    
    bracket (mmapFileForeignPtr filePath ReadOnly Nothing)
            (\_ -> return ()) $ \fptr -> do
        let (startPtr, size) = rangeToPtr fptr Nothing
        withForeignPtr fptr $ \_ -> do
            let loop offset count
                    | offset + patLen > size = return count
                    | otherwise = do
                        -- Check if pattern matches at this offset
                        matched <- checkMatch startPtr offset patBytes
                        let newCount = if matched then count + 1 else count
                        loop (offset + 1) newCount
            loop 0 0
  where
    checkMatch :: Ptr Word8 -> Int -> [Word8] -> IO Bool
    checkMatch base off pat = go 0 pat
      where
        go _ [] = return True
        go i (p:ps) = do
            b <- peek (base `plusPtr` (off + i))
            if b == p then go (i + 1) ps else return False

main :: IO ()
main = do
    count <- countPattern "/var/log/syslog" (BS.pack "error")
    putStrLn $ "Found 'error' " ++ show count ++ " times"

Best Practices for Haskell System Programming

Conclusion

Haskell brings a remarkable combination of safety, expressiveness, and performance to system programming. Its type system turns runtime disasters into compile-time errors, its lightweight concurrency model simplifies writing robust daemons and network services, and its FFI lets you reach down to C when you need raw speed or OS-specific features. The examples in this guide—from file I/O and process management to socket servers and signal handling—demonstrate that Haskell is not just viable for system programming but genuinely excels at it. By following the patterns and best practices outlined here, you can build system tools that are correct, maintainable, and surprisingly fast. The next time you reach for C or Python for a system task, consider whether Haskell's guarantees could save you hours of debugging and years of maintenance.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles