diff --git a/phino.cabal b/phino.cabal
--- a/phino.cabal
+++ b/phino.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: phino
-version: 0.0.120
+version: 0.0.121
 license: MIT
 synopsis: Command-Line Manipulator of 𝜑-Calculus Expressions
 description: Please see the README on GitHub at <https://github.com/objectionary/phino#readme>
diff --git a/src/Bytes.hs b/src/Bytes.hs
--- a/src/Bytes.hs
+++ b/src/Bytes.hs
@@ -15,6 +15,7 @@
   , unescapeStr
   , btsToNum
   , btsToUnescapedStr
+  , btsIsUtf8
   , btsAnd
   , btsOr
   , btsNot
@@ -328,6 +329,22 @@
 -- "5"
 btsToUnescapedStr :: Bytes -> String
 btsToUnescapedStr bytes = T.unpack (T.decodeUtf8 (B.pack (btsToWord8 bytes)))
+
+-- Whether the byte array is valid UTF-8. The string-side counterpart of the
+-- eight-byte check on numbers: a short or malformed datum is legal, and the
+-- printer keeps it in its byte form instead of aborting with an uncaught
+-- 'decodeUtf8' exception (see #1138).
+-- >>> btsIsUtf8 (BtMany ["77", "6F", "72", "6C", "64"])
+-- True
+-- >>> btsIsUtf8 (BtMany ["F0", "90", "80", "41"])
+-- False
+-- >>> btsIsUtf8 (BtOne "FE")
+-- False
+btsIsUtf8 :: Bytes -> Bool
+btsIsUtf8 bytes =
+  case T.decodeUtf8' (B.pack (btsToWord8 bytes)) of
+    Left _ -> False
+    Right _ -> True
 
 -- Bitwise conjunction of two byte arrays, byte by byte. EO's 'BytesRaw.and'
 -- refuses operands of different lengths, so there is nothing to yield for them
diff --git a/src/CST.hs b/src/CST.hs
--- a/src/CST.hs
+++ b/src/CST.hs
@@ -11,7 +11,7 @@
 module CST where
 
 import AST
-import Bytes (NonFinite, btsSize, btsToNonFinite, btsToNum, btsToStr)
+import Bytes (NonFinite, btsIsUtf8, btsSize, btsToNonFinite, btsToNum, btsToStr)
 import Data.Maybe (isJust)
 import qualified Data.Text as T
 import qualified Yaml as Y
@@ -276,9 +276,17 @@
   Right dbl | isNaN dbl || isInfinite dbl -> isJust (btsToNonFinite bts)
   _ -> True
 
+-- A string can be rendered as a literal only when its bytes decode as UTF-8.
+-- An arbitrary byte array is a legal datum and nothing promises it decodes, so
+-- a malformed one is kept in its byte form, exactly as a payload NaN is kept
+-- today (see #1138).
+sweetString :: Bytes -> Bool
+sweetString = btsIsUtf8
+
 -- Whether a data object may be collapsed into its sweet literal form.
 sweetCollapsible :: Expression -> Bool
 sweetCollapsible (DataNumber bts) = sweetNumber bts
+sweetCollapsible (DataString bts) = sweetString bts
 sweetCollapsible _ = True
 
 attributeToCST :: Attribute -> ATTRIBUTE
@@ -353,7 +361,7 @@
       withoutLastVoidRho [] = []
       withoutLastVoidRho [BiVoid AtRho] = []
       withoutLastVoidRho (bd : bds') = bd : withoutLastVoidRho bds'
-  toCST (DataString bts) (tabs, _) = EX_STRING (btsToStr bts) (TAB tabs) []
+  toCST (DataString bts) (tabs, _) | sweetString bts = EX_STRING (btsToStr bts) (TAB tabs) []
   -- The three canonical non-finite doubles have no sweet numeric literal, so
   -- they become the root dispatches `Φ.nan`, `Φ.pinf` and `Φ.ninf`. Any other
   -- non-finite pattern is left in its byte form `Φ.number(Φ.bytes(⟦ Δ ⤍ … ⟧))`
diff --git a/src/XMIR.hs b/src/XMIR.hs
--- a/src/XMIR.hs
+++ b/src/XMIR.hs
@@ -22,7 +22,7 @@
 where
 
 import AST
-import Bytes (btsSize, btsToNum, btsToStr, bytesToBts)
+import Bytes (btsIsUtf8, btsSize, btsToNum, btsToStr, bytesToBts)
 import Control.Exception (Exception (displayException), throwIO)
 import Data.Bifunctor (bimap)
 import Data.Foldable (foldlM)
@@ -131,7 +131,7 @@
           [object [("as", "data")] [NodeContent (T.pack (printBytes bytes))]]
    in pure
         ( "Φ.string"
-        , if _omitComments
+        , if _omitComments || not (btsIsUtf8 bytes)
             then [bts]
             else
               [ NodeComment (T.pack ('"' : btsToStr bytes ++ "\""))
diff --git a/test/CSTSpec.hs b/test/CSTSpec.hs
--- a/test/CSTSpec.hs
+++ b/test/CSTSpec.hs
@@ -155,6 +155,17 @@
       ]
       (\(desc, bts, expected) -> it desc (sweetNumber bts `shouldBe` expected))
 
+  describe "sweetString" $
+    forM_
+      [ ("is true for a valid ASCII datum", BtMany ["77", "6F", "72", "6C", "64"], True)
+      , ("is true for a valid multi-byte datum", BtMany ["D0", "B0"], True)
+      , ("is true for empty bytes", BtEmpty, True)
+      , ("is false for a lone continuation byte", BtOne "A0", False)
+      , ("is false for a truncated surrogate sequence", BtMany ["F0", "90", "80", "41"], False)
+      , ("is false for a lone 0xFE byte", BtOne "FE", False)
+      ]
+      (\(desc, bts, expected) -> it desc (sweetString bts `shouldBe` expected))
+
   describe "sweetCollapsible" $
     forM_
       [
diff --git a/test/PrinterSpec.hs b/test/PrinterSpec.hs
--- a/test/PrinterSpec.hs
+++ b/test/PrinterSpec.hs
@@ -116,6 +116,21 @@
             parseExpression printed `shouldBe` Right expr
       )
 
+  describe "printExpression keeps a string with a non-UTF-8 datum in byte form" $
+    forM_
+      [ ("a lone 0xFE", BtOne "FE")
+      , ("a truncated surrogate sequence", BtMany ["F0", "90", "80", "41"])
+      , ("a bare continuation byte", BtOne "A0")
+      ]
+      ( \(desc, bts) ->
+          it desc $ do
+            let expr = DataString bts
+                printed = printExpression' expr (SWEET, ASCII, SINGLELINE, defaultMargin)
+            printed `shouldContain` "string"
+            printed `shouldContain` "bytes"
+            parseExpression printed `shouldBe` Right expr
+      )
+
   describe "printExpression keeps a compressed meet atomic under a narrow margin" $
     -- A \phinoMeet is a single \overbracket visual unit, so its body must stay
     -- on one line even when the surrounding margin forces the outer formation to
