diff --git a/core-data.cabal b/core-data.cabal
--- a/core-data.cabal
+++ b/core-data.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           core-data
-version:        0.3.3.1
+version:        0.3.4.0
 synopsis:       Convenience wrappers around common data structures and encodings
 description:    Wrappers around common data structures and encodings.
                 .
@@ -36,10 +36,12 @@
   exposed-modules:
       Core.Data
       Core.Data.Clock
-      Core.Data.Format
       Core.Data.Structures
       Core.Encoding
+      Core.Encoding.External
       Core.Encoding.Json
+  other-modules:
+      Core.Data.Format
   hs-source-dirs:
       lib
   ghc-options: -Wall -Wwarn -fwarn-tabs
@@ -56,5 +58,6 @@
     , text
     , time
     , unordered-containers
+    , uuid
     , vector
   default-language: Haskell2010
diff --git a/lib/Core/Data/Clock.hs b/lib/Core/Data/Clock.hs
--- a/lib/Core/Data/Clock.hs
+++ b/lib/Core/Data/Clock.hs
@@ -79,38 +79,39 @@
 {- |
 Number of nanoseconds since the Unix epoch.
 
-The 'Show' instance displays the 'Time' as seconds with the nanosecond
-precision expressed as a decimal amount after the interger, ie:
+The 'Show' and 'Core.Encoding.External.Externalize' instances display the
+'Time' as seconds with the nanosecond precision expressed as a decimal amount
+after the interger, ie:
 
 >>> t <- getCurrentTimeNanoseconds
->>> show t
-2014-07-31T23:09:35.274387031Z
+>>> formatExternal t
+"2014-07-31T23:09:35.274387031Z"
 
 However this doesn't change the fact the underlying representation counts
 nanoseconds since epoch:
 
 >>> show $ unTime t
-1406848175274387031
+"1406848175274387031"
 
-There is a 'Read' instance that is reasonably accommodating:
+There is a 'Externalize' instance that is reasonably accommodating:
 
->>> read "2014-07-31T13:05:04.942089001Z" :: Time
-2014-07-31T13:05:04.942089001Z
+>>> parseExternal "2014-07-31T13:05:04.942089001Z" :: Maybe Time
+Just 2014-07-31T13:05:04.942089001Z
 
->>> read "1406811904.942089001" :: Time
-2014-07-31T13:05:04.942089001Z
+>>> parseExternal "1406811904.942089001" :: Maybe Time
+Just 2014-07-31T13:05:04.942089001Z
 
->>> read "1406811904" :: Time
-2014-07-31T13:05:04.000000000Z
+>>> parseExternal "1406811904" :: Maybe Time
+Just 2014-07-31T13:05:04.000000000Z
 
 In case you're wondering, the valid range of nanoseconds that fits into the
 underlying 'Int64' is:
 
->>> show $ minBound :: Time
-1677-09-21T00:12:43.145224192Z
+>>> formatExternal (minBound :: Time)
+"1677-09-21T00:12:43.145224192Z"
 
->>> show $ maxBound :: Time
-2262-04-11T23:47:16.854775807Z
+>>> formatExternal (maxBound :: Time)
+"2262-04-11T23:47:16.854775807Z"
 
 so in a quarter millenium's time, yes, you'll have the Y2262 Problem.
 Haskell code from today will, of course, still be running, so in the mid
diff --git a/lib/Core/Encoding.hs b/lib/Core/Encoding.hs
--- a/lib/Core/Encoding.hs
+++ b/lib/Core/Encoding.hs
@@ -18,6 +18,9 @@
 -}
 module Core.Encoding (
     module Core.Encoding.Json,
+
+    module Core.Encoding.External,
 ) where
 
+import Core.Encoding.External
 import Core.Encoding.Json
diff --git a/lib/Core/Encoding/External.hs b/lib/Core/Encoding/External.hs
new file mode 100644
--- /dev/null
+++ b/lib/Core/Encoding/External.hs
@@ -0,0 +1,213 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE ImportQualifiedPost #-}
+{-# LANGUAGE UndecidableInstances #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+{- |
+Quite frequently you will find yourself needing to convert between a rich
+semantic Haskell data type and a textual representation of that type which we
+call the /external/ representation of a value. The external representation of
+the value is authoriative and is meant to be re-readable even in the face of
+changing implemetations on the program side.
+
+Note that /externalizing/ is not quite the same as /serializing/. If you have
+more complex (ie rich types or nested) data structures then a simple text
+string will probably not be sufficient to convey sufficient information to
+represent it accurately. Serializing is focused on both performance encoding
+and decoding, and efficiency of the representation when transmitted over the
+wire. Of course, the obvious benefits of efficiency didn't stop the entire
+computer industry from near universal adoption of JSON as an interchange
+format, so there is, perhaps, no hope for us.
+
+You can, however, regain some of your sanity by ensuring that the individual
+fields of a larger structure are safe, and that's where the externalizing
+machinery in this module comes in.
+
+If you have read this far and think we are describing something similar to
+'Show' or @toString@ you are correct, but at the level of primative and simple
+types we are providing the ability to marshall them to a clean UTF-8
+representation and to unmarshall them back into Haskell values again.
+
+The other major use case for this module is as a helper to read user input;
+see 'Core.Program.Execute.queryOptionValue'' for an example that makes use of
+this.
+
+/Notes for implementators/
+
+Postel's dictum to \"be conservative in what you produce but liberal in what
+you accept\" describes the intent of this module. If you are implementing an
+instance of 'Externalize' then you might consider being flexible as possible
+when parsing with 'parseExternal', within the constraints of having to read a
+given value with exact fidelity. But when outputing a value with
+'formatExternal' you should be correcting the representation of the value to a
+canonical, stable form, even if the original input was written differently.
+See the discussion of creating 'Core.Data.Clock.Time' types from varying
+inputs for an example.
+-}
+module Core.Encoding.External (
+    -- * Conversions
+    Externalize (formatExternal, parseExternal),
+) where
+
+import Core.Data.Clock
+import Core.Text.Rope
+import Data.ByteString.Builder qualified as Builder
+import Data.Int (Int32, Int64)
+import Data.Scientific (FPFormat (Exponent), Scientific, formatScientific)
+import Data.UUID qualified as Uuid (UUID, fromText, toText)
+import Text.Read (readMaybe)
+
+{- |
+Convert between the internal Haskell representation of a data type and an
+external, textual form suitable for visualization, onward transmission, or
+archival storage.
+
+It is expected that a valid instance of 'Externalize' allows you to round-trip
+through it:
+
+>>> formatExternal (42 :: Int))
+"42"
+
+>>> fromJust (parseExternal "42") :: Int
+42
+
+with the usual caveat about needing to ensure you've given enough information
+to the type-checker to know which instance you're asking for.
+
+There is a general implementatation that goes though 'Show' and 'Read' via
+'String' but if you know you have a direct way to render or parse a type into
+a sequence of characters then you can offer an instance of 'Externalize' which
+does so more efficiently.
+
+@since 0.3.4
+-}
+class Externalize ξ where
+    -- | Convert a value into an authoritative, stable textual representation
+    -- for use externally.
+    formatExternal :: ξ -> Rope
+
+    -- | Attempt to read an external textual representation into a Haskell value.
+    parseExternal :: Rope -> Maybe ξ
+
+--
+-- We use this general instance here rather than as a super class constraint
+-- for Externalize so as to allow us to have things that can be externalized
+-- without necessarily needing those two instances. Most things have Show, but
+-- not everything, as many many types haven't bothered with Read.
+--
+
+instance {-# OVERLAPPABLE #-} (Read a, Show a) => Externalize a where
+    formatExternal = intoRope . show
+    parseExternal = readMaybe . fromRope
+
+instance Externalize Rope where
+    formatExternal = id
+    parseExternal = Just
+
+instance Externalize String where
+    formatExternal = packRope
+    parseExternal = Just . fromRope
+
+--
+-- These weren't really necessary, but they're worth it as an example of
+-- avoiding Show & Read
+--
+
+{- |
+Integers are represented in decimal:
+
+@
+42
+@
+-}
+instance Externalize Int where
+    formatExternal = intoRope . Builder.toLazyByteString . Builder.intDec
+    parseExternal = readMaybe . fromRope
+
+{- |
+Integers are represented in decimal:
+
+@
+42
+@
+-}
+instance Externalize Int32 where
+    formatExternal = intoRope . Builder.toLazyByteString . Builder.int32Dec
+    parseExternal = readMaybe . fromRope
+
+{- |
+Integers are represented in decimal:
+
+@
+42
+@
+-}
+instance Externalize Int64 where
+    formatExternal = intoRope . Builder.toLazyByteString . Builder.int64Dec
+    parseExternal = readMaybe . fromRope
+
+{- |
+IEEE 754 floating point:
+
+@
+3.1415927
+@
+-}
+instance Externalize Float where
+    formatExternal = intoRope . Builder.toLazyByteString . Builder.floatDec
+    parseExternal = readMaybe . fromRope
+
+{- |
+IEEE 754 floating point:
+
+@
+3.141592653589793
+@
+-}
+instance Externalize Double where
+    formatExternal = intoRope . Builder.toLazyByteString . Builder.doubleDec
+    parseExternal = readMaybe . fromRope
+
+--
+-- More than anything, THIS was the example that motivated creating this
+-- module.
+--
+
+{- |
+Unique identifiers are formatted as per RFC 4122:
+
+@
+6937e157-d041-4919-8690-4d6c12b7e0e3
+@
+-}
+instance Externalize Uuid.UUID where
+    formatExternal = intoRope . Uuid.toText
+    parseExternal = Uuid.fromText . fromRope
+
+--
+-- This is a placeholder to remind that if we ever improve the machinery in
+-- Core.Data.Clock to not use **hourglass** (which uses String) we could quite
+-- likely get a better implementation here.
+--
+
+{- |
+Timestamps are formatted as per ISO 8601:
+
+@
+2022-06-20T14:51:23.544826062Z
+@
+-}
+instance Externalize Time where
+    formatExternal = intoRope . show
+    parseExternal = readMaybe . fromRope
+
+{- |
+Numbers are converted to scientific notation:
+
+@
+2.99792458e8
+@
+-}
+instance Externalize Scientific where
+    formatExternal = intoRope . formatScientific Exponent Nothing
+    parseExternal = readMaybe . fromRope
