diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,1 @@
+# Changelog
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2026 drlkf
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1.  Redistributions of source code must retain the above copyright notice, this
+    list of conditions and the following disclaimer.
+
+2.  Redistributions in binary form must reproduce the above copyright notice,
+    this list of conditions and the following disclaimer in the documentation
+    and/or other materials provided with the distribution.
+
+3.  Neither the name of the copyright holder nor the names of its contributors
+    may be used to endorse or promote products derived from this software
+    without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,20 @@
+# effectful-data-cache-memcache
+
+`runCacheMemcache` interprets `Cache` against memcached using the
+[memcache](https://hackage.haskell.org/package/memcache) client.
+
+```haskell
+import Database.Memcache.Client (def, newClient)
+import Effectful.Cache.Memcache
+
+codec :: Codec String Int
+codec = Codec BC.pack (BC.pack . show) (readMaybe . BC.unpack)
+
+main = do
+  c <- newClient [def] def
+  runEff . runCacheMemcache codec c Nothing $ insert "key" (42 :: Int)
+```
+
+memcached cannot enumerate items: `keys`, `filterWithKey` and `size` throw
+`MemcacheUnsupported`. `lookup'` behaves like `lookup` and `purgeExpired` is
+a no-op. Expirations are rounded up to whole seconds.
diff --git a/effectful-data-cache-memcache.cabal b/effectful-data-cache-memcache.cabal
new file mode 100644
--- /dev/null
+++ b/effectful-data-cache-memcache.cabal
@@ -0,0 +1,70 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.39.6.
+--
+-- see: https://github.com/sol/hpack
+
+name:           effectful-data-cache-memcache
+version:        0.2.1.0
+synopsis:       memcached backend for effectful-data-cache
+description:    Please see the README on GitHub at <https://github.com/haskell-github-trust/effectful-data-cache/tree/main/effectful-data-cache-memcache#readme>
+category:       cache
+homepage:       https://github.com/haskell-github-trust/effectful-data-cache#readme
+bug-reports:    https://github.com/haskell-github-trust/effectful-data-cache/issues
+author:         drlkf
+maintainer:     drlkf <drlkf@drlkf.net>
+copyright:      2026 drlkf
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+extra-doc-files:
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/haskell-github-trust/effectful-data-cache
+
+library
+  exposed-modules:
+      Effectful.Cache.Memcache
+  other-modules:
+      Paths_effectful_data_cache_memcache
+  autogen-modules:
+      Paths_effectful_data_cache_memcache
+  hs-source-dirs:
+      src
+  ghc-options: -Wall -Wextra -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
+  build-depends:
+      base >=4.7 && <5
+    , bytestring >=0.11 && <0.13
+    , clock ==0.8.*
+    , effectful-core >=2.3 && <3
+    , effectful-data-cache ==0.2.*
+    , memcache ==0.3.*
+  default-language: Haskell2010
+
+test-suite effectful-data-cache-memcache-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Effectful.Cache.MemcacheSpec
+      Paths_effectful_data_cache_memcache
+  autogen-modules:
+      Paths_effectful_data_cache_memcache
+  hs-source-dirs:
+      test
+  ghc-options: -Wall -Wextra -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
+  build-tool-depends:
+      hspec-discover:hspec-discover
+  build-depends:
+      base >=4.7 && <5
+    , bytestring
+    , clock
+    , effectful-core
+    , effectful-data-cache
+    , effectful-data-cache-memcache
+    , hspec
+    , memcache
+  default-language: Haskell2010
diff --git a/src/Effectful/Cache/Memcache.hs b/src/Effectful/Cache/Memcache.hs
new file mode 100644
--- /dev/null
+++ b/src/Effectful/Cache/Memcache.hs
@@ -0,0 +1,105 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NumericUnderscores #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators #-}
+
+-- | Interpret 'Cache' against a memcached server via "Database.Memcache.Client".
+--
+-- > c <- newClient [def] def
+-- > runEff . runCacheMemcache codec c Nothing $ insert "a" (1 :: Int)
+--
+-- memcached cannot enumerate its items, so 'Keys', 'FilterWithKey' and 'Size'
+-- throw 'MemcacheUnsupported'. Expired items are never returned, so 'Lookup''
+-- behaves like 'Lookup', and 'PurgeExpired' is a no-op.
+module Effectful.Cache.Memcache (
+  Codec (..),
+  MemcacheUnsupported (..),
+  runCacheMemcache,
+) where
+
+import Control.Exception (Exception, throwIO)
+import Control.Monad (void)
+import Data.ByteString (ByteString)
+import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import Data.Word (Word32)
+import qualified Database.Memcache.Client as M
+import Effectful (Eff, IOE, MonadIO (liftIO), type (:>))
+import Effectful.Cache (Cache (..))
+import Effectful.Dispatch.Dynamic (interpret)
+import System.Clock (Clock (Realtime), TimeSpec (..), getTime, toNanoSecs)
+
+-- | How keys and values are stored. A value that fails to decode is a miss.
+--
+-- @since 0.1.0.0
+data Codec k v = Codec
+  { encodeKey :: k -> ByteString
+  , encodeValue :: v -> ByteString
+  , decodeValue :: ByteString -> Maybe v
+  }
+
+-- | Thrown by operations memcached has no equivalent for.
+--
+-- @since 0.1.0.0
+newtype MemcacheUnsupported = MemcacheUnsupported String
+  deriving Show
+
+instance Exception MemcacheUnsupported
+
+-- | Run against a memcached client with the given default expiration.
+--
+-- @since 0.1.0.0
+runCacheMemcache
+  :: forall k v es a
+   . IOE :> es
+  => Codec k v
+  -> M.Client
+  -> Maybe TimeSpec
+  -> Eff (Cache k v : es) a
+  -> Eff es a
+runCacheMemcache codec c ts eff = do
+  ref <- liftIO (newIORef ts)
+  interpret (\_ op -> liftIO (withMemcache codec c ref op)) eff
+
+withMemcache
+  :: Codec k v
+  -> M.Client
+  -> IORef (Maybe TimeSpec)
+  -> Cache k v m a
+  -> IO a
+withMemcache codec c ref = \case
+  Insert k v -> readIORef ref >>= \ts -> set ts k v
+  Insert' ts k v -> set ts k v
+  Lookup k -> get k
+  Lookup' k -> get k
+  Keys -> unsupported "keys"
+  Delete k -> del k
+  FilterWithKey _ -> unsupported "filterWithKey"
+  Purge -> M.flush c Nothing
+  PurgeExpired -> pure ()
+  Size -> unsupported "size"
+  DefaultExpiration -> readIORef ref
+  SetDefaultExpiration ts -> writeIORef ref ts
+ where
+  key = encodeKey codec
+  get k = (>>= \(v, _, _) -> decodeValue codec v) <$> M.get c (key k)
+  del k = void (M.delete c (key k) 0)
+  set ts k v =
+    expiration ts >>= \case
+      Nothing -> del k
+      Just e -> void (M.set c (key k) (encodeValue codec v) 0 e)
+  unsupported op = throwIO (MemcacheUnsupported op)
+
+-- | memcached expirations are whole seconds, 0 never expires, and values over
+-- 30 days are unix timestamps. 'Nothing' means already expired.
+expiration :: Maybe TimeSpec -> IO (Maybe Word32)
+expiration Nothing = pure (Just 0)
+expiration (Just ts)
+  | secs <= 0 = pure Nothing
+  | secs <= thirtyDays = pure (Just (fromInteger secs))
+  | otherwise = Just . fromInteger . (+ secs) . toInteger . sec <$> getTime Realtime
+ where
+  secs = negate (toNanoSecs ts `div` negate 1_000_000_000)
+  thirtyDays = 30 * 24 * 3600
diff --git a/test/Effectful/Cache/MemcacheSpec.hs b/test/Effectful/Cache/MemcacheSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Effectful/Cache/MemcacheSpec.hs
@@ -0,0 +1,86 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE NumericUnderscores #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Effectful.Cache.MemcacheSpec (spec) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception (SomeException, try)
+import qualified Data.ByteString.Char8 as BC
+import qualified Database.Memcache.Client as M
+import Effectful
+import Effectful.Cache
+import Effectful.Cache.Memcache
+import System.Clock (TimeSpec (..))
+import Test.Hspec
+import Text.Read (readMaybe)
+import Prelude hiding (lookup)
+
+codec :: Codec String Int
+codec = Codec BC.pack (BC.pack . show) (readMaybe . BC.unpack)
+
+connect :: IO (Maybe M.Client)
+connect = do
+  c <- M.newClient [M.def] M.def
+  r <- try @SomeException (M.version c)
+  pure (c <$ either (const Nothing) Just r)
+
+withClient :: (M.Client -> IO ()) -> Maybe M.Client -> IO ()
+withClient _ Nothing = pendingWith "memcached not reachable on 127.0.0.1:11211"
+withClient f (Just c) = M.flush c Nothing >> f c
+
+run :: M.Client -> Maybe TimeSpec -> Eff '[Cache String Int, IOE] a -> IO a
+run c ts = runEff . runCacheMemcache codec c ts
+
+spec :: Spec
+spec = beforeAll connect $ do
+  it "looks up what it inserted" . withClient $ \c -> do
+    r <- run c Nothing $ insert "a" (1 :: Int) >> lookup @String @Int "a"
+    r `shouldBe` Just 1
+
+  it "misses on an absent key" . withClient $ \c -> do
+    r <- run c Nothing $ lookup @String @Int "nope"
+    r `shouldBe` Nothing
+
+  it "deletes" . withClient $ \c -> do
+    r <- run c Nothing $ do
+      insert "a" (1 :: Int)
+      delete @String @Int "a"
+      lookup @String @Int "a"
+    r `shouldBe` Nothing
+
+  it "purges" . withClient $ \c -> do
+    r <- run c Nothing $ do
+      insert "a" (1 :: Int)
+      purge @String @Int
+      lookup @String @Int "a"
+    r `shouldBe` Nothing
+
+  it "treats a non-positive expiration as already expired" . withClient $ \c -> do
+    r <- run c Nothing $ do
+      insert "a" (1 :: Int)
+      insert' (Just (TimeSpec (-1) 0)) "a" (2 :: Int)
+      lookup @String @Int "a"
+    r `shouldBe` Nothing
+
+  it "expires after the default expiration" . withClient $ \c -> do
+    (d, r) <- run c (Just (TimeSpec 0 1)) $ do
+      insert "a" (1 :: Int)
+      liftIO (threadDelay 2_100_000)
+      (,) <$> defaultExpiration @String @Int <*> lookup @String @Int "a"
+    d `shouldBe` Just (TimeSpec 0 1)
+    r `shouldBe` Nothing
+
+  it "sets the default expiration" . withClient $ \c -> do
+    d <- run c Nothing $ do
+      setDefaultExpiration @String @Int (Just (TimeSpec 5 0))
+      defaultExpiration @String @Int
+    d `shouldBe` Just (TimeSpec 5 0)
+
+  it "misses on an undecodable value" . withClient $ \c -> do
+    _ <- M.set c (BC.pack "a") (BC.pack "junk") 0 0
+    r <- run c Nothing $ lookup @String @Int "a"
+    r `shouldBe` Nothing
+
+  it "rejects keys" . withClient $ \c ->
+    run c Nothing (keys @String @Int) `shouldThrow` \(MemcacheUnsupported _) -> True
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
