diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 0.1.5.2
+- Fix a connection leak: `close` and the connection finalizer built the close action but then discarded it, so the DuckDB connection and database handles stayed open. Every leaked database instance also kept its own DuckDB thread pool alive. (Reported by @winitzki, see #15.)
+- Fix the same defect in `closeStatement`, which discarded the action that destroys the prepared statement. (Fixed by @bgamari in #15.)
+- Add a `duckdb-simple-leak-test` test suite that runs many open/close cycles and fails if the thread count or the resident set size of the process grows.
+
 ## 0.1.5.1
 - Re-export the `RowParser` data constructor from `Database.DuckDB.Simple.FromRow`, restoring the API that `0.1.5.0` unintentionally broke (see #6). Downstream packages such as `beam-duckdb` rely on this constructor. (Sorry!)
 
diff --git a/duckdb-simple.cabal b/duckdb-simple.cabal
--- a/duckdb-simple.cabal
+++ b/duckdb-simple.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.4
 name: duckdb-simple
-version: 0.1.5.1
+version: 0.1.5.2
 license: MPL-2.0
 license-file: LICENSE
 author: Matthias Pall Gissurarson
@@ -76,7 +76,7 @@
   other-modules: Properties
   default-language: Haskell2010
   build-depends:
-    QuickCheck >=2.14 && <2.16,
+    QuickCheck >=2.14 && <2.18,
     array >=0.5 && <0.6,
     base >=4.14 && <5,
     bytestring,
@@ -91,3 +91,13 @@
     text,
     time,
     uuid >=1.3 && <1.4,
+
+test-suite duckdb-simple-leak-test
+  type: exitcode-stdio-1.0
+  hs-source-dirs: leaktest
+  main-is: Main.hs
+  default-language: Haskell2010
+  ghc-options: -threaded
+  build-depends:
+    base >=4.14 && <5,
+    duckdb-simple,
diff --git a/leaktest/Main.hs b/leaktest/Main.hs
new file mode 100644
--- /dev/null
+++ b/leaktest/Main.hs
@@ -0,0 +1,114 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Regression check for leaked DuckDB handles.
+
+The handles live in C memory, so the GHC heap statistics cannot see them.
+Each leaked database instance keeps its own DuckDB thread pool alive, thus
+this check compares the number of operating-system threads and the resident
+set size of the process before and after many open\/close cycles.  Both
+values are process-global, thus the check has its own test suite and does
+not share a process with the other tests.
+
+The check reads @\/proc\/self\/status@.  On a system without @\/proc@ the
+check reports a skip and exits with success.
+-}
+module Main (main) where
+
+import Control.Exception (IOException, evaluate, try)
+import Control.Monad (forM_, when)
+import Data.Int (Int64)
+import Data.List (isPrefixOf)
+import Data.Maybe (listToMaybe)
+import Database.DuckDB.Simple
+import System.Exit (exitFailure)
+
+-- | The process-global resource counters that a leaked handle increases.
+data Usage = Usage
+    { usageThreads :: Int
+    , usageRssKb :: Int
+    }
+    deriving (Show)
+
+-- | The number of open\/close cycles that the check runs.
+cycles :: Int
+cycles = 32
+
+-- | The largest thread growth that is not a leak.
+threadSlack :: Int
+threadSlack = 8
+
+-- | The largest resident-set growth, in kB, that is not a leak.
+rssSlackKb :: Int
+rssSlackKb = 32 * 1024
+
+{- | Read the current resource counters.  The result is 'Nothing' if the system
+has no @\/proc\/self\/status@.
+-}
+readUsage :: IO (Maybe Usage)
+readUsage = do
+    result <- try (readFile "/proc/self/status") :: IO (Either IOException String)
+    case result of
+        Left _ -> pure Nothing
+        Right contents -> do
+            _ <- evaluate (length contents)
+            pure (Usage <$> statusField "Threads" contents <*> statusField "VmRSS" contents)
+  where
+    statusField name contents =
+        listToMaybe
+            [ value
+            | line <- lines contents
+            , (name <> ":") `isPrefixOf` line
+            , (value, _) <- reads (drop (length name + 1) line)
+            ]
+
+-- | Open a connection, use a statement, then close both.
+openCloseCycle :: IO ()
+openCloseCycle = do
+    conn <- open ":memory:"
+    stmt <- openStatement conn "SELECT 42"
+    closeStatement stmt
+    _ <- query_ conn "SELECT 42" :: IO [Only Int64]
+    close conn
+
+main :: IO ()
+main = do
+    -- The first cycle also does the one-time initialization, which must not
+    -- count as growth.
+    openCloseCycle
+    before <- readUsage
+    case before of
+        Nothing -> putStrLn "duckdb-simple leak check: /proc/self/status is unavailable; skipped."
+        Just baseline -> do
+            forM_ [1 .. cycles] \_ -> openCloseCycle
+            after <- readUsage
+            case after of
+                Nothing -> putStrLn "duckdb-simple leak check: /proc/self/status disappeared; skipped."
+                Just final -> report baseline final
+
+-- | Print both measurements and fail if either one grew too much.
+report :: Usage -> Usage -> IO ()
+report before after = do
+    putStrLn $ "cycles: " <> show cycles
+    putStrLn $
+        "threads: "
+            <> show (usageThreads before)
+            <> " -> "
+            <> show (usageThreads after)
+            <> " (slack "
+            <> show threadSlack
+            <> ")"
+    putStrLn $
+        "rss kB: "
+            <> show (usageRssKb before)
+            <> " -> "
+            <> show (usageRssKb after)
+            <> " (slack "
+            <> show rssSlackKb
+            <> ")"
+    when (threadGrowth > threadSlack || rssGrowth > rssSlackKb) do
+        putStrLn "FAIL: open/close leaks DuckDB resources"
+        exitFailure
+  where
+    threadGrowth = usageThreads after - usageThreads before
+    rssGrowth = usageRssKb after - usageRssKb before
diff --git a/src/Database/DuckDB/Simple.hs b/src/Database/DuckDB/Simple.hs
--- a/src/Database/DuckDB/Simple.hs
+++ b/src/Database/DuckDB/Simple.hs
@@ -150,7 +150,7 @@
 -- | Close a connection.  The operation is idempotent.
 close :: Connection -> IO ()
 close Connection{connectionState} =
-    void $
+    join $
         atomicModifyIORef' connectionState \case
             ConnectionClosed -> (ConnectionClosed, pure ())
             openState@(ConnectionOpen{}) ->
@@ -188,11 +188,11 @@
 closeStatement :: Statement -> IO ()
 closeStatement stmt@Statement{statementState} = do
     resetStatementStream stmt
-    void $
-        atomicModifyIORef' statementState \case
-            StatementClosed -> (StatementClosed, pure ())
-            StatementOpen{statementHandle} ->
-                (StatementClosed, destroyPrepared statementHandle)
+    finish <- atomicModifyIORef' statementState \case
+        StatementClosed -> (StatementClosed, pure ())
+        StatementOpen{statementHandle} ->
+            (StatementClosed, destroyPrepared statementHandle)
+    finish
 
 -- | Run an action with a prepared statement, closing it afterwards.
 withStatement :: Connection -> Query -> (Statement -> IO a) -> IO a
@@ -716,7 +716,7 @@
     ref <- newIORef (ConnectionOpen db conn)
     _ <-
         mkWeakIORef ref $
-            void $
+            join $
                 atomicModifyIORef' ref \case
                     ConnectionClosed -> (ConnectionClosed, pure ())
                     openState@(ConnectionOpen{}) ->
