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:
- Reading and writing files with fine-grained control
- Spawning and managing external processes
- Working with sockets, pipes, and network protocols
- Handling signals and system events
- Calling C libraries via the Foreign Function Interface (FFI)
- Managing memory and low-level data structures when necessary
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:
- Type safety — The compiler catches entire classes of bugs at compile time, including null pointer errors, race conditions (in pure code), and incorrect resource handling patterns.
- Concurrency — Haskell's lightweight green threads (via
forkIO) and Software Transactional Memory (STM) make concurrent system daemons remarkably easy to write correctly. - Maintainability — Pure functions and strong typing mean system tools written in Haskell tend to survive long maintenance cycles without accumulating technical debt.
- Performance — The Glasgow Haskell Compiler (GHC) generates native code that competes favorably with C in many workloads, especially when using the
ByteStringandTextlibraries. - Expressiveness — Higher-order functions and algebraic data types allow you to model complex system behaviors with minimal boilerplate.
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
- Use
ByteStringfor binary data — The strictByteStringtype avoids memory leaks from lazy I/O and performs excellently for bulk data processing. - Always bracket resources — Use
bracket,withFile, orfinallyto guarantee cleanup of file handles, sockets, and allocated memory, even in the presence of exceptions. - Prefer
Concurrentlyfrom theasyncpackage — For managing concurrent tasks,Asyncgives you structured concurrency with proper exception propagation and cancellation. - Handle signals early — Install signal handlers at program startup, before any critical resources are acquired, to ensure clean shutdown paths exist.
- Keep FFI boundaries thin — Wrap C calls in small, type-safe Haskell functions. Never expose raw pointers or C types in your public API.
- Use
ForeignPtrfor GC-managed C allocations — When allocating memory in C, attach a finalizer usingForeignPtrso the GC can free it automatically. - Profile with ThreadScope — System programs often have complex concurrency patterns. ThreadScope visualizes thread activity and helps identify bottlenecks.
- Log structured data — Instead of unstructured strings, use a logging library like
katipthat emits structured JSON logs, making system monitoring easier. - Test with property-based testing — Libraries like
QuickCheckcan generate edge cases for your system interactions (file sizes, network payloads, signal timing) that manual testing would miss.
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.