diff --git a/src/metabase/api/alert.clj b/src/metabase/api/alert.clj
index 0976d8c8856d9389b7c60b60b9106cd2dc6a065c..276bb3908d377f05d60c943828babbd38f297bb4 100644
--- a/src/metabase/api/alert.clj
+++ b/src/metabase/api/alert.clj
@@ -14,8 +14,9 @@
              [card :refer [Card]]
              [interface :as mi]
              [pulse :as pulse :refer [Pulse]]]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan
              [db :as db]
diff --git a/src/metabase/api/automagic_dashboards.clj b/src/metabase/api/automagic_dashboards.clj
index 27a2a15930bee77a7423624542471c4be590f069..6856f8e0eff73557af3ee7f9dcdd2fe88fa7a009 100644
--- a/src/metabase/api/automagic_dashboards.clj
+++ b/src/metabase/api/automagic_dashboards.clj
@@ -17,8 +17,9 @@
              [segment :refer [Segment]]
              [table :refer [Table]]]
             [metabase.models.query.permissions :as query-perms]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]]
             [ring.util.codec :as codec]
             [schema.core :as s]))
 
diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj
index 4a8ac62236506c33d13b4a0a7b796143bfea48a5..fc9cf849ab7faf1a2edd506f3eb2b8fd40253e9d 100644
--- a/src/metabase/api/card.clj
+++ b/src/metabase/api/card.clj
@@ -33,8 +33,9 @@
              [cache :as cache]
              [results-metadata :as results-metadata]]
             [metabase.sync.analyze.query-results :as qr]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util
+             [i18n :refer [trs tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan
              [db :as db]
diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj
index 6e604e7a057c4c8aaf01d30bda8f31cc14a3ab83..ccabf2512fd6b7951f0e3ce4948a16e5c6dcd7cc 100644
--- a/src/metabase/api/common.clj
+++ b/src/metabase/api/common.clj
@@ -14,8 +14,9 @@
              [util :as u]]
             [metabase.api.common.internal :refer :all]
             [metabase.models.interface :as mi]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util
+             [i18n :as ui18n :refer [trs tru]]
+             [schema :as su]]
             [ring.core.protocols :as protocols]
             [ring.util.response :as response]
             [schema.core :as s]
@@ -75,9 +76,10 @@
                                       [code-or-code-message-pair rest-args]
                                       [[code-or-code-message-pair (first rest-args)] (rest rest-args)])]
      (when-not tst
-       (throw (if (map? message)
-                (ex-info (:message message) (assoc message :status-code code))
-                (ex-info message            {:status-code code}))))
+       (throw (if (and (map? message)
+                       (not (ui18n/localized-string? message)))
+                (ui18n/ex-info (:message message) (assoc message :status-code code))
+                (ui18n/ex-info message            {:status-code code}))))
      (if (empty? rest-args) tst
          (recur (first rest-args) (second rest-args) (drop 2 rest-args))))))
 
@@ -100,7 +102,7 @@
 (defn throw-invalid-param-exception
   "Throw an `ExceptionInfo` that contains information about an invalid API params in the expected format."
   [field-name message]
-  (throw (ex-info (tru "Invalid field: {0}" field-name)
+  (throw (ui18n/ex-info (tru "Invalid field: {0}" field-name)
            {:status-code 400
             :errors      {(keyword field-name) message}})))
 
@@ -216,23 +218,23 @@
 
 ;; #### GENERIC 403 RESPONSE HELPERS
 ;; If you can't be bothered to write a custom error message
-(def ^:private generic-403
+(defn- generic-403 []
   [403 (tru "You don''t have permissions to do that.")])
 
 (defn check-403
   "Throw a `403` (no permissions) if `arg` is `false` or `nil`, otherwise return as-is."
   [arg]
-  (check arg generic-403))
+  (check arg (generic-403)))
 (defmacro let-403
   "Bind a form as with `let`; throw a 403 if it is `nil` or `false`."
   {:style/indent 1}
   [& body]
-  `(api-let ~generic-403 ~@body))
+  `(api-let (generic-403) ~@body))
 
 (defn throw-403
   "Throw a generic 403 (no permissions) error response."
   []
-  (throw (ex-info (tru "You don''t have permissions to do that.") {:status-code 403})))
+  (throw (ui18n/ex-info (tru "You don''t have permissions to do that.") {:status-code 403})))
 
 ;; #### GENERIC 500 RESPONSE HELPERS
 ;; For when you don't feel like writing something useful
diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj
index 7ee3fc4b5d866aa825a41b593e5e6858ea7e4fef..4976ed9e5b45304a0dc46f5197d50438694a3112 100644
--- a/src/metabase/api/common/internal.clj
+++ b/src/metabase/api/common/internal.clj
@@ -6,8 +6,9 @@
             [medley.core :as m]
             [metabase.config :as config]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util
+             [i18n :as ui18n :refer [tru]]
+             [schema :as su]]
             [schema.core :as s]))
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
@@ -111,7 +112,7 @@
   [^String value]
   (try (Integer/parseInt value)
        (catch NumberFormatException _
-         (throw (ex-info (format "Not a valid integer: '%s'" value) {:status-code 400})))))
+         (throw (ui18n/ex-info (tru "Not a valid integer: ''{0}''" value) {:status-code 400})))))
 
 (def ^:dynamic *auto-parse-types*
   "Map of `param-type` -> map with the following keys:
@@ -218,7 +219,7 @@
   [field-name value schema]
   (try (s/validate schema value)
        (catch Throwable e
-         (throw (ex-info (tru "Invalid field: {0}" field-name)
+         (throw (ui18n/ex-info (tru "Invalid field: {0}" field-name)
                   {:status-code 400
                    :errors      {(keyword field-name) (or (su/api-error-message schema)
                                                           (:message (ex-data e))
diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj
index 22874aa0256942ca0fb303b89dcab8ebde8600fe..196d61793d318a3102c5e79b0e433dfbee3c831a 100644
--- a/src/metabase/api/dataset.clj
+++ b/src/metabase/api/dataset.clj
@@ -14,8 +14,8 @@
             [metabase.util
              [date :as du]
              [export :as ex]
+             [i18n :refer [trs tru]]
              [schema :as su]]
-            [puppetlabs.i18n.core :refer [trs tru]]
             [schema.core :as s]))
 
 ;;; -------------------------------------------- Running a Query Normally --------------------------------------------
diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj
index c339c925b3a52c4bccb9954c5ea122e3ac35be08..6cbe57238f0d6529c6e66ca5749a5e40b7aaec1d 100644
--- a/src/metabase/api/embed.clj
+++ b/src/metabase/api/embed.clj
@@ -31,6 +31,7 @@
             [metabase.util :as u]
             [metabase.util
              [embed :as eu]
+             [i18n :refer [tru]]
              [schema :as su]]
             [schema.core :as s]
             [toucan.db :as db]))
@@ -253,7 +254,7 @@
    (api/check-404 object)
    (api/check-not-archived object)
    (api/check (:enable_embedding object)
-     [400 "Embedding is not enabled for this object."])))
+     [400 (tru "Embedding is not enabled for this object.")])))
 
 (def ^:private ^{:arglists '([dashboard-id])} check-embedding-enabled-for-dashboard
   (partial check-embedding-enabled-for-object Dashboard))
diff --git a/src/metabase/api/geojson.clj b/src/metabase/api/geojson.clj
index 96209270ed718351154b52f77ae78b0757bfa51c..6aaf4938748c34a981816c9d43c8cda919488c68 100644
--- a/src/metabase/api/geojson.clj
+++ b/src/metabase/api/geojson.clj
@@ -5,10 +5,12 @@
             [metabase.api.common :refer [defendpoint define-routes]]
             [metabase.models.setting :as setting :refer [defsetting]]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :as ui18n :refer [tru]]
+             [schema :as su]]
             [ring.util.response :as rr]
-            [schema.core :as s])
+            [schema.core :as s]
+            [metabase.util.i18n :as ui18n])
   (:import org.apache.commons.io.input.ReaderInputStream))
 
 (def ^:private ^:const ^Integer geojson-fetch-timeout-ms
@@ -87,7 +89,7 @@
   [key]
   {key su/NonBlankString}
   (let [url (or (get-in (custom-geojson) [(keyword key) :url])
-                (throw (ex-info (tru "Invalid custom GeoJSON key: {0}" key)
+                (throw (ui18n/ex-info (tru "Invalid custom GeoJSON key: {0}" key)
                          {:status-code 400})))]
     ;; TODO - it would be nice if we could also avoid returning our usual cache-busting headers with the response here
     (-> (rr/response (ReaderInputStream. (io/reader url)))
diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj
index e3758dd68100dbfe043c1fd91c2b4237a75bae29..d483074b0cc133d86202a5cc9ab942940bd004c9 100644
--- a/src/metabase/api/public.clj
+++ b/src/metabase/api/public.clj
@@ -24,8 +24,8 @@
              [params :as params]]
             [metabase.util
              [embed :as embed]
+             [i18n :refer [tru]]
              [schema :as su]]
-            [puppetlabs.i18n.core :refer [tru]]
             [schema.core :as s]
             [toucan
              [db :as db]
diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj
index 0026542367bc3b4ad7d671552ae7db6332d2c0fd..7941a535449464e8080ae222f7773f3f7a56ece6 100644
--- a/src/metabase/api/pulse.clj
+++ b/src/metabase/api/pulse.clj
@@ -19,9 +19,9 @@
              [pulse-channel :refer [channel-types]]]
             [metabase.pulse.render :as render]
             [metabase.util
+             [i18n :refer [tru]]
              [schema :as su]
              [urls :as urls]]
-            [puppetlabs.i18n.core :refer [tru]]
             [schema.core :as s]
             [toucan
              [db :as db]
diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj
index bbbb5e900e54d696828c54ee2a26f240b3a53d6e..e5babb2903699e7fe24ab57aa422d204f78694fe 100644
--- a/src/metabase/api/routes.clj
+++ b/src/metabase/api/routes.clj
@@ -34,7 +34,7 @@
              [user :as user]
              [util :as util]]
             [metabase.middleware :as middleware]
-            [puppetlabs.i18n.core :refer [tru]]))
+            [metabase.util.i18n :refer [tru]]))
 
 (def ^:private +generic-exceptions
   "Wrap ROUTES so any Exception thrown is just returned as a generic 400, to prevent details from leaking in public
diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj
index 22e80070ba80718aa45933a0aee013b716db1678..69a9c6a29ec19f024d22d120b34a5b8503fc1524 100644
--- a/src/metabase/api/session.clj
+++ b/src/metabase/api/session.clj
@@ -17,9 +17,9 @@
              [setting :refer [defsetting]]
              [user :as user :refer [User]]]
             [metabase.util
+             [i18n :as ui18n :refer [trs tru]]
              [password :as pass]
              [schema :as su]]
-            [puppetlabs.i18n.core :refer [trs tru]]
             [schema.core :as s]
             [throttle.core :as throttle]
             [toucan.db :as db]))
@@ -56,7 +56,7 @@
         (when-not (ldap/verify-password user-info password)
           ;; Since LDAP knows about the user, fail here to prevent the local strategy to be tried with a possibly
           ;; outdated password
-          (throw (ex-info password-fail-message
+          (throw (ui18n/ex-info password-fail-message
                    {:status-code 400
                     :errors      {:password password-fail-snippet}})))
         ;; password is ok, return new session
@@ -85,7 +85,7 @@
       (email-login username password) ; Then try local authentication
       ;; If nothing succeeded complain about it
       ;; Don't leak whether the account doesn't exist or the password was incorrect
-      (throw (ex-info password-fail-message
+      (throw (ui18n/ex-info password-fail-message
                {:status-code 400
                 :errors      {:password password-fail-snippet}}))))
 
@@ -189,10 +189,10 @@
 (defn- google-auth-token-info [^String token]
   (let [{:keys [status body]} (http/post (str "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" token))]
     (when-not (= status 200)
-      (throw (ex-info (tru "Invalid Google Auth token.") {:status-code 400})))
+      (throw (ui18n/ex-info (tru "Invalid Google Auth token.") {:status-code 400})))
     (u/prog1 (json/parse-string body keyword)
       (when-not (= (:email_verified <>) "true")
-        (throw (ex-info (tru "Email is not verified.") {:status-code 400}))))))
+        (throw (ui18n/ex-info (tru "Email is not verified.") {:status-code 400}))))))
 
 ;; TODO - are these general enough to move to `metabase.util`?
 (defn- email->domain ^String [email]
@@ -211,7 +211,7 @@
     ;; Use some wacky status code (428 - Precondition Required) so we will know when to so the error screen specific
     ;; to this situation
     (throw
-     (ex-info (tru "You''ll need an administrator to create a Metabase account before you can use Google to log in.")
+     (ui18n/ex-info (tru "You''ll need an administrator to create a Metabase account before you can use Google to log in.")
        {:status-code 428}))))
 
 (s/defn ^:private google-auth-create-new-user!
diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj
index 55e3e0b371ed8831f4d36e24721aac7ffed37de2..944b3e79b194d23a6332c2745029621b83ed6fad 100644
--- a/src/metabase/api/setup.clj
+++ b/src/metabase/api/setup.clj
@@ -14,8 +14,9 @@
              [database :refer [Database]]
              [session :refer [Session]]
              [user :as user :refer [User]]]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan.db :as db]))
 
diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj
index 05378ec5ea0cb8ddde16df45cb4d014f26653d53..9c073d5f1487f68068b3db29f0b15104fd988dd4 100644
--- a/src/metabase/api/table.clj
+++ b/src/metabase/api/table.clj
@@ -19,7 +19,7 @@
             [metabase.sync.field-values :as sync-field-values]
             [metabase.util.schema :as su]
             [schema.core :as s]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util.i18n :refer [trs tru]]
             [toucan
              [db :as db]
              [hydrate :refer [hydrate]]]))
@@ -156,13 +156,13 @@
                               (pred v))) dimension-options-for-response)))
 
 (def ^:private date-default-index
-  (dimension-index-for-type "type/DateTime" #(= day-str (:name %))))
+  (dimension-index-for-type "type/DateTime" #(= (str day-str) (str (:name %)))))
 
 (def ^:private numeric-default-index
-  (dimension-index-for-type "type/Number" #(.contains ^String (:name %) auto-bin-str)))
+  (dimension-index-for-type "type/Number" #(.contains ^String (str (:name %)) (str auto-bin-str))))
 
 (def ^:private coordinate-default-index
-  (dimension-index-for-type "type/Coordinate" #(.contains ^String (:name %) auto-bin-str)))
+  (dimension-index-for-type "type/Coordinate" #(.contains ^String (str (:name %)) (str auto-bin-str))))
 
 (defn- supports-numeric-binning? [driver]
   (and driver (contains? (driver/features driver) :binning)))
diff --git a/src/metabase/api/tiles.clj b/src/metabase/api/tiles.clj
index 6a242975a38332ddd7bafc1c803330652a9d9ffa..449f4e39f9db79533ed05100b5c2dda9d475cbf2 100644
--- a/src/metabase/api/tiles.clj
+++ b/src/metabase/api/tiles.clj
@@ -7,8 +7,9 @@
              [query-processor :as qp]
              [util :as u]]
             [metabase.api.common :as api]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]])
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]])
   (:import java.awt.Color
            java.awt.image.BufferedImage
            java.io.ByteArrayOutputStream
diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj
index cee471889faf679b4d329c730ee2f6f1b1b30e80..4b3bd9a79b1853eedb78df32567e37f482f79e8e 100644
--- a/src/metabase/api/user.clj
+++ b/src/metabase/api/user.clj
@@ -9,8 +9,9 @@
             [metabase.integrations.ldap :as ldap]
             [metabase.models.user :as user :refer [User]]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan
              [db :as db]
diff --git a/src/metabase/automagic_dashboards/populate.clj b/src/metabase/automagic_dashboards/populate.clj
index 55df966a4feb900ada67aa8b9a3b2e0e173899a2..b5930f17aed95e2641b515b15daf5921e361af6d 100644
--- a/src/metabase/automagic_dashboards/populate.clj
+++ b/src/metabase/automagic_dashboards/populate.clj
@@ -9,7 +9,7 @@
              [collection :as collection]]
             [metabase.query-processor.util :as qp.util]
             [metabase.util :as u]
-            [puppetlabs.i18n.core :as i18n :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [toucan.db :as db]))
 
 (def ^Long grid-width
@@ -275,10 +275,10 @@
                                      ;; Height doesn't need to be precise, just some
                                      ;; safe upper bound.
                                      (make-grid grid-width (* n grid-width))]))]
-     (log/infof (trs "Adding %s cards to dashboard %s:\n%s")
-                (count cards)
-                title
-                (str/join "; " (map :title cards)))
+     (log/info (trs "Adding {0} cards to dashboard {1}:\n{2}"
+                    (count cards)
+                    title
+                    (str/join "; " (map :title cards))))
      (cond-> dashboard
        (not-empty filters) (filters/add-filters filters max-filters)))))
 
diff --git a/src/metabase/automagic_dashboards/rules.clj b/src/metabase/automagic_dashboards/rules.clj
index e137acc20333ba3f0b212e88e3fe8f3301f3b351..22b7d3987fc59f598e5d4ee830b731c8edf9f705 100644
--- a/src/metabase/automagic_dashboards/rules.clj
+++ b/src/metabase/automagic_dashboards/rules.clj
@@ -6,8 +6,9 @@
             [metabase.automagic-dashboards.populate :as populate]
             [metabase.query-processor.util :as qp.util]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :as i18n :refer [trs]]
+            [metabase.util
+             [i18n :refer [trs]]
+             [schema :as su]]
             [schema
              [coerce :as sc]
              [core :as s]]
@@ -298,13 +299,13 @@
           rules-validator
           (as-> rule (assoc rule :specificity (specificity rule)))))
     (catch Exception e
-      (log/errorf (trs "Error parsing %s:\n%s")
-                  (.getFileName f)
-                  (or (some-> e
-                              ex-data
-                              (select-keys [:error :value])
-                              u/pprint-to-str)
-                      e))
+      (log/error (trs "Error parsing {0}:\n{1}"
+                      (.getFileName f)
+                      (or (some-> e
+                                  ex-data
+                                  (select-keys [:error :value])
+                                  u/pprint-to-str)
+                          e)))
       nil)))
 
 (defn- trim-trailing-slash
diff --git a/src/metabase/cmd/reset_password.clj b/src/metabase/cmd/reset_password.clj
index 944a49c0da62406c15d98e74950799919a8d80d4..ce912ecc8fe3bad72903833f37523f71da8c9329 100644
--- a/src/metabase/cmd/reset_password.clj
+++ b/src/metabase/cmd/reset_password.clj
@@ -1,7 +1,7 @@
 (ns metabase.cmd.reset-password
   (:require [metabase.db :as mdb]
             [metabase.models.user :refer [User] :as user]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [toucan.db :as db]))
 
 (defn- set-reset-token!
diff --git a/src/metabase/core.clj b/src/metabase/core.clj
index dcaa9cc47ed5c1be55f2e14b851c5acc69974ba2..f2fc15fb4cb53c354f330eb1197fc5a732a951be 100644
--- a/src/metabase/core.clj
+++ b/src/metabase/core.clj
@@ -22,8 +22,8 @@
             [metabase.models
              [setting :as setting]
              [user :refer [User]]]
-            [metabase.util.i18n :refer [set-locale]]
-            [puppetlabs.i18n.core :refer [locale-negotiator trs]]
+            [metabase.util.i18n :refer [set-locale trs]]
+            [puppetlabs.i18n.core :refer [locale-negotiator]]
             [ring.adapter.jetty :as ring-jetty]
             [ring.middleware
              [cookies :refer [wrap-cookies]]
diff --git a/src/metabase/db.clj b/src/metabase/db.clj
index 375451011125f9cf4dec0b707804a923e796038c..336c792924da5539220b8d1bc00e45606760caa2 100644
--- a/src/metabase/db.clj
+++ b/src/metabase/db.clj
@@ -11,7 +11,7 @@
              [config :as config]
              [util :as u]]
             [metabase.db.spec :as dbspec]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [ring.util.codec :as codec]
             [toucan.db :as db])
   (:import com.mchange.v2.c3p0.ComboPooledDataSource
diff --git a/src/metabase/db/migrations.clj b/src/metabase/db/migrations.clj
index fae7e2ab0780181f8e80d4ce9771c1eac40772a2..b36b050a06b0d96453934d2a790a425cc4bf0c8c 100644
--- a/src/metabase/db/migrations.clj
+++ b/src/metabase/db/migrations.clj
@@ -30,8 +30,9 @@
              [setting :as setting :refer [Setting]]
              [user :refer [User]]]
             [metabase.query-processor.util :as qputil]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util
+             [date :as du]
+             [i18n :refer [trs]]]
             [toucan
              [db :as db]
              [models :as models]]
@@ -363,9 +364,9 @@
     (doseq [group-id non-admin-group-ids]
       (perms/grant-collection-readwrite-permissions! group-id collection/root-collection))
     ;; 2. Create the new collections.
-    (doseq [[model new-collection-name] {Dashboard (trs "Migrated Dashboards")
-                                         Pulse     (trs "Migrated Pulses")
-                                         Card      (trs "Migrated Questions")}
+    (doseq [[model new-collection-name] {Dashboard (str (trs "Migrated Dashboards"))
+                                         Pulse     (str (trs "Migrated Pulses"))
+                                         Card      (str (trs "Migrated Questions"))}
             :when                       (db/exists? model :collection_id nil)
             :let                        [new-collection (db/insert! Collection
                                                           :name  new-collection-name
diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj
index 8beb896a480d826e1e7108400c510a0ff3baf60d..c15665872720200567df67121725c4cdc7134618 100644
--- a/src/metabase/driver.clj
+++ b/src/metabase/driver.clj
@@ -23,8 +23,9 @@
              [database :refer [Database]]
              [setting :refer [defsetting]]]
             [metabase.sync.interface :as si]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util
+             [date :as du]
+             [i18n :refer [trs tru]]]
             [schema.core :as s]
             [toucan.db :as db])
   (:import clojure.lang.Keyword
diff --git a/src/metabase/driver/bigquery.clj b/src/metabase/driver/bigquery.clj
index b08ec3f8a192cd2e5200df995101960922ed1abc..4eb74e77f16c14dc576067b6e348feb56e673063 100644
--- a/src/metabase/driver/bigquery.clj
+++ b/src/metabase/driver/bigquery.clj
@@ -27,14 +27,13 @@
              [util :as qputil]]
             [metabase.util
              [date :as du]
-             [honeysql-extensions :as hx]]
-            [puppetlabs.i18n.core :refer [tru]]
+             [honeysql-extensions :as hx]
+             [i18n :refer [tru]]]
             [toucan.db :as db])
   (:import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
            com.google.api.client.http.HttpRequestInitializer
            [com.google.api.services.bigquery Bigquery Bigquery$Builder BigqueryScopes]
-           [com.google.api.services.bigquery.model QueryRequest QueryResponse Table TableCell TableFieldSchema TableList
-            TableList$Tables TableReference TableRow TableSchema]
+           [com.google.api.services.bigquery.model QueryRequest QueryResponse Table TableCell TableFieldSchema TableList TableList$Tables TableReference TableRow TableSchema]
            honeysql.format.ToSql
            java.sql.Time
            [java.util Collections Date]
diff --git a/src/metabase/driver/crate.clj b/src/metabase/driver/crate.clj
index 7957a4388b26f67a688c552c124a9a82f6cc8df1..0f31c8259a046bc2399f6633127210538dfd4a44 100644
--- a/src/metabase/driver/crate.clj
+++ b/src/metabase/driver/crate.clj
@@ -7,7 +7,7 @@
              [util :as u]]
             [metabase.driver.crate.util :as crate-util]
             [metabase.driver.generic-sql :as sql]
-            [puppetlabs.i18n.core :refer [tru]])
+            [metabase.util.i18n :refer [tru]])
   (:import java.sql.DatabaseMetaData))
 
 (def ^:private ^:const column->base-type
diff --git a/src/metabase/driver/druid.clj b/src/metabase/driver/druid.clj
index 673e06269d6569ae17cbc5a512eba3a10337fa5d..11018b9cb091567aaf6e06bf62d2d4e194142eac 100644
--- a/src/metabase/driver/druid.clj
+++ b/src/metabase/driver/druid.clj
@@ -7,8 +7,9 @@
              [driver :as driver]
              [util :as u]]
             [metabase.driver.druid.query-processor :as qp]
-            [metabase.util.ssh :as ssh]
-            [puppetlabs.i18n.core :refer [tru]]))
+            [metabase.util
+             [i18n :refer [tru]]
+             [ssh :as ssh]]))
 
 ;;; ### Request helper fns
 
diff --git a/src/metabase/driver/generic_sql/query_processor.clj b/src/metabase/driver/generic_sql/query_processor.clj
index 67afd813db4ae1346004361e3f5286cf32e905d7..324a1d4b2b7216b503ce6bc21fc7ae405c037b15 100644
--- a/src/metabase/driver/generic_sql/query_processor.clj
+++ b/src/metabase/driver/generic_sql/query_processor.clj
@@ -16,8 +16,8 @@
              [util :as qputil]]
             [metabase.util
              [date :as du]
-             [honeysql-extensions :as hx]]
-            [puppetlabs.i18n.core :refer [trs]])
+             [honeysql-extensions :as hx]
+             [i18n :refer [trs]]])
   (:import [java.sql PreparedStatement ResultSet ResultSetMetaData SQLException]
            [java.util Calendar Date TimeZone]
            [metabase.query_processor.interface AgFieldRef BinnedField DateTimeField DateTimeValue Expression
diff --git a/src/metabase/driver/googleanalytics.clj b/src/metabase/driver/googleanalytics.clj
index 7d83b9de51cf2ab6c40baf3bc2ecdd1f729e428d..10b9ceed69741666d5c7527395a8234ded275fa2 100644
--- a/src/metabase/driver/googleanalytics.clj
+++ b/src/metabase/driver/googleanalytics.clj
@@ -7,7 +7,7 @@
             [metabase.driver.google :as google]
             [metabase.driver.googleanalytics.query-processor :as qp]
             [metabase.models.database :refer [Database]]
-            [puppetlabs.i18n.core :refer [tru]])
+            [metabase.util.i18n :refer [tru]])
   (:import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
            [com.google.api.services.analytics Analytics Analytics$Builder Analytics$Data$Ga$Get AnalyticsScopes]
            [com.google.api.services.analytics.model Column Columns Profile Profiles Webproperties Webproperty]
@@ -228,8 +228,8 @@
   ;; if we get a big long message about how we need to enable the GA API, then replace it with a short message about
   ;; how we need to enable the API
   (if-let [[_ enable-api-url] (re-find #"Enable it by visiting ([^\s]+) then retry." message)]
-    (tru "You must enable the Google Analytics API. Use this link to go to the Google Developers Console: {0}"
-         enable-api-url)
+    (str (tru "You must enable the Google Analytics API. Use this link to go to the Google Developers Console: {0}"
+              enable-api-url))
     message))
 
 
diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj
index 85c6252c1c66d9a7d81def03abae7da006dc96f1..c08570eece5b9b9cf448aef5d9a3476f90b2b5ad 100644
--- a/src/metabase/driver/h2.clj
+++ b/src/metabase/driver/h2.clj
@@ -8,8 +8,9 @@
             [metabase.db.spec :as dbspec]
             [metabase.driver.generic-sql :as sql]
             [metabase.models.database :refer [Database]]
-            [metabase.util.honeysql-extensions :as hx]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [honeysql-extensions :as hx]
+             [i18n :refer [tru]]]
             [toucan.db :as db]))
 
 (def ^:private ^:const column->base-type
diff --git a/src/metabase/driver/mongo.clj b/src/metabase/driver/mongo.clj
index 7b0c797bd62ae017b4ccbf6d13419a6066793b19..2eadd139720e03f5c455e2e7a708b59e007d721e 100644
--- a/src/metabase/driver/mongo.clj
+++ b/src/metabase/driver/mongo.clj
@@ -15,7 +15,7 @@
              [command :as cmd]
              [conversion :as conv]
              [db :as mdb]]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util.i18n :refer [tru]]
             [schema.core :as s]
             [toucan.db :as db])
   (:import com.mongodb.DB))
diff --git a/src/metabase/driver/oracle.clj b/src/metabase/driver/oracle.clj
index 307b91a7581613c1fd0da07d1e9f3b669e26d92a..76d9ba1574a40dd6050b2ea5f6f8753623d26098 100644
--- a/src/metabase/driver/oracle.clj
+++ b/src/metabase/driver/oracle.clj
@@ -12,8 +12,8 @@
             [metabase.driver.generic-sql.query-processor :as sqlqp]
             [metabase.util
              [honeysql-extensions :as hx]
-             [ssh :as ssh]]
-            [puppetlabs.i18n.core :refer [tru]]))
+             [i18n :refer [tru]]
+             [ssh :as ssh]]))
 
 (defrecord OracleDriver []
   :load-ns true
diff --git a/src/metabase/driver/presto.clj b/src/metabase/driver/presto.clj
index 381d63a0aad495b4fc0496a8f5f7441116ccc2ed..0d904ca291f4a1c2a0bd39bbb024f2bf2e2e641d 100644
--- a/src/metabase/driver/presto.clj
+++ b/src/metabase/driver/presto.clj
@@ -22,11 +22,11 @@
             [metabase.util
              [date :as du]
              [honeysql-extensions :as hx]
-             [ssh :as ssh]]
-            [puppetlabs.i18n.core :refer [tru]])
+             [i18n :refer [tru]]
+             [ssh :as ssh]])
   (:import java.sql.Time
            java.util.Date
-           [metabase.query_processor.interface TimeValue]))
+           metabase.query_processor.interface.TimeValue))
 
 (defrecord PrestoDriver []
   :load-ns true
diff --git a/src/metabase/driver/redshift.clj b/src/metabase/driver/redshift.clj
index ed40906ac7883a16970f7434737b05475c3b127d..09a8416949a1806763f94999a9e118e0b76177c2 100644
--- a/src/metabase/driver/redshift.clj
+++ b/src/metabase/driver/redshift.clj
@@ -12,8 +12,8 @@
              [postgres :as postgres]]
             [metabase.util
              [honeysql-extensions :as hx]
-             [ssh :as ssh]]
-            [puppetlabs.i18n.core :refer [tru]]))
+             [i18n :refer [tru]]
+             [ssh :as ssh]]))
 
 (defn- connection-details->spec
   "Create a database specification for a redshift database. Opts should include
diff --git a/src/metabase/driver/sparksql.clj b/src/metabase/driver/sparksql.clj
index 045351ed0804ac005101ce38530e3fb1725ffe84..30287b96a4a398c4f6b6f5acd6e933096b1cb3cc 100644
--- a/src/metabase/driver/sparksql.clj
+++ b/src/metabase/driver/sparksql.clj
@@ -17,8 +17,9 @@
             [metabase.driver.generic-sql.query-processor :as sqlqp]
             [metabase.models.table :refer [Table]]
             [metabase.query-processor.util :as qputil]
-            [metabase.util.honeysql-extensions :as hx]
-            [puppetlabs.i18n.core :refer [trs tru]])
+            [metabase.util
+             [honeysql-extensions :as hx]
+             [i18n :refer [trs tru]]])
   (:import clojure.lang.Reflector
            java.sql.DriverManager
            metabase.query_processor.interface.Field))
diff --git a/src/metabase/driver/sqlite.clj b/src/metabase/driver/sqlite.clj
index 95a5ac30bf957aafa2ab8fc16a0605a3e4d6b2fb..a15923faeb3345eace80985a5f299be8b6767ffb 100644
--- a/src/metabase/driver/sqlite.clj
+++ b/src/metabase/driver/sqlite.clj
@@ -14,8 +14,8 @@
             [metabase.driver.generic-sql.query-processor :as sqlqp]
             [metabase.util
              [date :as du]
-             [honeysql-extensions :as hx]]
-            [puppetlabs.i18n.core :refer [tru]]
+             [honeysql-extensions :as hx]
+             [i18n :refer [tru]]]
             [schema.core :as s])
   (:import [java.sql Time Timestamp]))
 
diff --git a/src/metabase/driver/sqlserver.clj b/src/metabase/driver/sqlserver.clj
index 328d006a00df1987e75a1e75a6deab32c543e52f..348e28c1a6358472e2bc6d2394bd550326c1fe39 100644
--- a/src/metabase/driver/sqlserver.clj
+++ b/src/metabase/driver/sqlserver.clj
@@ -9,8 +9,8 @@
             [metabase.driver.generic-sql.query-processor :as sqlqp]
             [metabase.util
              [honeysql-extensions :as hx]
-             [ssh :as ssh]]
-            [puppetlabs.i18n.core :refer [tru]])
+             [i18n :refer [tru]]
+             [ssh :as ssh]])
   (:import java.sql.Time))
 
 (defrecord SQLServerDriver []
diff --git a/src/metabase/email.clj b/src/metabase/email.clj
index bed7e78cdefe3a68612ff4a26b2144481ffcc931..829f25d2d07a85b5c93cb1181731dc5a7e83e593 100644
--- a/src/metabase/email.clj
+++ b/src/metabase/email.clj
@@ -2,11 +2,12 @@
   (:require [clojure.tools.logging :as log]
             [metabase.models.setting :as setting :refer [defsetting]]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
+            [metabase.util
+             [i18n :refer [tru trs]]
+             [schema :as su]]
             [postal
              [core :as postal]
              [support :refer [make-props]]]
-            [puppetlabs.i18n.core :refer [tru trs]]
             [schema.core :as s])
   (:import javax.mail.Session))
 
@@ -72,7 +73,7 @@
   {:style/indent 0}
   [{:keys [subject recipients message-type message]} :- EmailMessage]
   (when-not (email-smtp-host)
-    (let [^String msg (tru "SMTP host is not set.")]
+    (let [^String msg (str (tru "SMTP host is not set."))]
       (throw (Exception. msg))))
   ;; Now send the email
   (send-email! (smtp-settings)
diff --git a/src/metabase/integrations/ldap.clj b/src/metabase/integrations/ldap.clj
index 27ba7f9cefbb92c18d28fd65708282b066d8e993..e2a9992b8edd4996e6b0b19b2ab280969d7a1bc2 100644
--- a/src/metabase/integrations/ldap.clj
+++ b/src/metabase/integrations/ldap.clj
@@ -8,7 +8,7 @@
              [setting :as setting :refer [defsetting]]
              [user :as user :refer [User]]]
             [metabase.util :as u]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util.i18n :refer [tru]]
             [toucan.db :as db])
   (:import [com.unboundid.ldap.sdk LDAPConnectionPool LDAPException]))
 
diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj
index 9d130ec3ea48fd1f23ad549b8e52e1e881f37aac..97df78f092a442ec7b71a0ec5bdb8d8b0459303c 100644
--- a/src/metabase/integrations/slack.clj
+++ b/src/metabase/integrations/slack.clj
@@ -3,7 +3,7 @@
             [clj-http.client :as http]
             [clojure.tools.logging :as log]
             [metabase.models.setting :as setting :refer [defsetting]]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util.i18n :refer [tru]]
             [metabase.util :as u]))
 
 ;; Define a setting which captures our Slack api token
diff --git a/src/metabase/metabot.clj b/src/metabase/metabot.clj
index 0134657b8a8b60d812c971e907ec90695d556598..90f25595b0d9a9c42ae4fed61f64d1f240a07a7c 100644
--- a/src/metabase/metabot.clj
+++ b/src/metabase/metabot.clj
@@ -24,8 +24,8 @@
              [setting :as setting :refer [defsetting]]]
             [metabase.util
              [date :as du]
+             [i18n :refer [trs tru]]
              [urls :as urls]]
-            [puppetlabs.i18n.core :refer [trs tru]]
             [throttle.core :as throttle]
             [toucan.db :as db]))
 
@@ -173,21 +173,21 @@
                              dispatch-token) varr}))]
     (fn dispatch*
       ([]
-       (keys-description (tru "Here''s what I can {0}:" verb) fn-map))
+       (keys-description (str (tru "Here''s what I can {0}:" verb)) fn-map))
       ([what & args]
        (if-let [f (fn-map (keyword what))]
          (apply f args)
-         (tru "I don''t know how to {0} `{1}`.\n{2}"
-                 verb
-                 (if (instance? clojure.lang.Named what)
-                   (name what)
-                   what)
-                 (dispatch*)))))))
+         (str (tru "I don''t know how to {0} `{1}`.\n{2}"
+                   verb
+                   (if (instance? clojure.lang.Named what)
+                     (name what)
+                     what)
+                   (dispatch*))))))))
 
 (defn- format-exception
   "Format a `Throwable` the way we'd like for posting it on slack."
   [^Throwable e]
-  (tru "Uh oh! :cry:\n> {0}" (.getMessage e)))
+  (str (tru "Uh oh! :cry:\n> {0}" (.getMessage e))))
 
 (defmacro ^:private do-async {:style/indent 0} [& body]
   `(future (try ~@body
@@ -207,7 +207,7 @@
   [& _]
   (let [cards (with-metabot-permissions
                 (filterv mi/can-read? (db/select [Card :id :name :dataset_query :collection_id], {:order-by [[:id :desc]], :limit 20})))]
-    (tru "Here''s your {0} most recent cards:\n{1}" (count cards) (format-cards cards))))
+    (str (tru "Here''s your {0} most recent cards:\n{1}" (count cards) (format-cards cards)))))
 
 (defn- card-with-name [card-name]
   (first (u/prog1 (db/select [Card :id :name], :%lower.name [:like (str \% (str/lower-case card-name) \%)])
@@ -229,7 +229,7 @@
 (defn ^:metabot show
   "Implementation of the `metabot show card <name-or-id>` command."
   ([]
-   (tru "Show which card? Give me a part of a card name or its ID and I can show it to you. If you don''t know which card you want, try `metabot list`."))
+   (str (tru "Show which card? Give me a part of a card name or its ID and I can show it to you. If you don''t know which card you want, try `metabot list`.")))
   ([card-id-or-name]
    (if-let [{card-id :id} (id-or-name->card card-id-or-name)]
      (do
@@ -241,7 +241,7 @@
                    (slack/post-chat-message! *channel-id*
                                              nil
                                              attachments)))
-       (tru "Ok, just a second..."))
+       (str (tru "Ok, just a second...")))
      (throw (Exception. (str (tru "Not Found"))))))
   ;; If the card name comes without spaces, e.g. (show 'my 'wacky 'card) turn it into a string an recur: (show "my
   ;; wacky card")
diff --git a/src/metabase/middleware.clj b/src/metabase/middleware.clj
index 8601269a8d5bda38d4c1988f0d55d8a57b2af2e0..0491fb69ea6641cc55fe19d145ff1be15d8c40e1 100644
--- a/src/metabase/middleware.clj
+++ b/src/metabase/middleware.clj
@@ -15,8 +15,9 @@
              [session :refer [Session]]
              [setting :refer [defsetting]]
              [user :as user :refer [User]]]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [date :as du]
+             [i18n :as ui18n :refer [tru]]]
             [toucan.db :as db])
   (:import com.fasterxml.jackson.core.JsonGenerator
            java.sql.SQLException))
@@ -393,11 +394,11 @@
                                           ;; Field validation exceptions. Return those as is
                                           (and status-code
                                                (seq other-info))
-                                          other-info
+                                          (ui18n/localized-strings->strings other-info)
                                           ;; If status code was specified but other data wasn't, it's something like a
                                           ;; 404. Return message as the (plain-text) body.
                                           status-code
-                                          message
+                                          (str message)
                                           ;; Otherwise it's a 500. Return a body that includes exception & filtered
                                           ;; stacktrace for debugging purposes
                                           :else
@@ -412,7 +413,9 @@
                                                                  #"\s*\n\s*")}))))]
     {:status  (or status-code 500)
      :headers (cond-> (html-page-security-headers)
-                (string? body) (assoc "Content-Type" "text/plain"))
+                (or (string? body)
+                    (ui18n/localized-string? body))
+                (assoc "Content-Type" "text/plain"))
      :body    body}))
 
 (defn catch-api-exceptions
diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj
index dc2bbe1f138cf893c84887f77d40d366dfc75ce7..9cfb90ddc0dfc8fdb54cd92f5efadec981b7e275 100644
--- a/src/metabase/models/card.clj
+++ b/src/metabase/models/card.clj
@@ -17,8 +17,9 @@
              [revision :as revision]]
             [metabase.models.query.permissions :as query-perms]
             [metabase.query-processor.util :as qputil]
-            [metabase.util.query :as q]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :as ui18n :refer [tru]]
+             [query :as q]]
             [toucan
              [db :as db]
              [models :as models]]))
@@ -81,12 +82,12 @@
 
         (ids-already-seen source-card-id)
         (throw
-         (ex-info (tru "Cannot save Question: source query has circular references.")
+         (ui18n/ex-info (tru "Cannot save Question: source query has circular references.")
            {:status-code 400}))
 
         :else
         (recur (or (db/select-one-field :dataset_query Card :id source-card-id)
-                   (throw (ex-info (tru "Card {0} does not exist." source-card-id)
+                   (throw (ui18n/ex-info (tru "Card {0} does not exist." source-card-id)
                             {:status-code 404})))
                (conj ids-already-seen source-card-id))))))
 
diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj
index a3973eea4047e8ce3ebac40bd0c50aca411441de..ea9e3ce215dbe91809a79e077050634e7bb5f8ab 100644
--- a/src/metabase/models/collection.clj
+++ b/src/metabase/models/collection.clj
@@ -17,8 +17,9 @@
              [interface :as i]
              [permissions :as perms :refer [Permissions]]]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util
+             [i18n :as ui18n :refer [trs tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan
              [db :as db]
@@ -42,13 +43,13 @@
 (defn- assert-valid-hex-color [^String hex-color]
   (when (or (not (string? hex-color))
             (not (re-matches hex-color-regex hex-color)))
-    (throw (ex-info (tru "Invalid color")
+    (throw (ui18n/ex-info (tru "Invalid color")
              {:status-code 400, :errors {:color (tru "must be a valid 6-character hex color code")}}))))
 
 (defn- slugify [collection-name]
   ;; double-check that someone isn't trying to use a blank string as the collection name
   (when (str/blank? collection-name)
-    (throw (ex-info (tru "Collection name cannot be blank!")
+    (throw (ui18n/ex-info (tru "Collection name cannot be blank!")
              {:status-code 400, :errors {:name (tru "cannot be blank")}})))
   (u/slugify collection-name collection-slug-max-length))
 
@@ -141,20 +142,20 @@
   (when (contains? collection :location)
     (when-not (valid-location-path? location)
       (throw
-       (ex-info (tru "Invalid Collection location: path is invalid.")
+       (ui18n/ex-info (tru "Invalid Collection location: path is invalid.")
          {:status-code 400
           :errors      {:location (tru "Invalid Collection location: path is invalid.")}})))
     ;; if this is a Personal Collection it's only allowed to go in the Root Collection: you can't put it anywhere else!
     (when (contains? collection :personal_owner_id)
       (when-not (= location "/")
         (throw
-         (ex-info (tru "You cannot move a Personal Collection.")
+         (ui18n/ex-info (tru "You cannot move a Personal Collection.")
            {:status-code 400
             :errors      {:location (tru "You cannot move a Personal Collection.")}}))))
     ;; Also make sure that all the IDs referenced in the Location path actually correspond to real Collections
     (when-not (all-ids-in-location-path-are-valid? location)
       (throw
-       (ex-info (tru "Invalid Collection location: some or all ancestors do not exist.")
+       (ui18n/ex-info (tru "Invalid Collection location: some or all ancestors do not exist.")
          {:status-code 404
           :errors      {:location (tru "Invalid Collection location: some or all ancestors do not exist.")}})))))
 
@@ -188,7 +189,7 @@
   "The special Root Collection placeholder object with some extra details to facilitate displaying it on the FE."
   []
   (assoc root-collection
-    :name (tru "Our analytics")
+    :name (str (tru "Our analytics"))
     :id   "root"))
 
 (defn- is-root-collection? [x]
@@ -616,7 +617,7 @@
   ;; double-check and make sure it's not just the existing value getting passed back in for whatever reason
   (when (api/column-will-change? :personal_owner_id collection-before-updates collection-updates)
     (throw
-     (ex-info (tru "You're not allowed to change the owner of a Personal Collection.")
+     (ui18n/ex-info (tru "You're not allowed to change the owner of a Personal Collection.")
        {:status-code 400
         :errors      {:personal_owner_id (tru "You're not allowed to change the owner of a Personal Collection.")}})))
   ;;
@@ -627,13 +628,13 @@
   ;; You also definitely cannot *move* a Personal Collection
   (when (api/column-will-change? :location collection-before-updates collection-updates)
     (throw
-     (ex-info (tru "You're not allowed to move a Personal Collection.")
+     (ui18n/ex-info (tru "You're not allowed to move a Personal Collection.")
        {:status-code 400
         :errors      {:location (tru "You're not allowed to move a Personal Collection.")}})))
   ;; You also can't archive a Personal Collection
   (when (api/column-will-change? :archived collection-before-updates collection-updates)
     (throw
-     (ex-info (tru "You cannot archive a Personal Collection.")
+     (ui18n/ex-info (tru "You cannot archive a Personal Collection.")
        {:status-code 400
         :errors      {:archived (tru "You cannot archive a Personal Collection.")}}))))
 
@@ -644,7 +645,7 @@
   (when (api/column-will-change? :archived collection-before-updates collection-updates)
     ;; check to make sure we're not trying to change location at the same time
     (when (api/column-will-change? :location collection-before-updates collection-updates)
-      (throw (ex-info (tru "You cannot move a Collection and archive it at the same time.")
+      (throw (ui18n/ex-info (tru "You cannot move a Collection and archive it at the same time.")
                {:status-code 400
                 :errors      {:archived (tru "You cannot move a Collection and archive it at the same time.")}})))
     ;; ok, go ahead and do the archive/unarchive operation
@@ -966,7 +967,7 @@
   ;; the same first & last name! This will *ruin* their lives :(
   (let [{first-name :first_name, last-name :last_name} (db/select-one ['User :first_name :last_name]
                                                          :id (u/get-id user-or-id))]
-    (tru "{0} {1}''s Personal Collection" first-name last-name)))
+    (str (tru "{0} {1}''s Personal Collection" first-name last-name))))
 
 (s/defn user->personal-collection :- CollectionInstance
   "Return the Personal Collection for `user-or-id`, if it already exists; if not, create it and return it."
diff --git a/src/metabase/models/collection_revision.clj b/src/metabase/models/collection_revision.clj
index c367c5b14b139462e18b11b2c5f5150906a49c95..f953724f2fac897776df691912a516b4de1702a2 100644
--- a/src/metabase/models/collection_revision.clj
+++ b/src/metabase/models/collection_revision.clj
@@ -1,7 +1,8 @@
 (ns metabase.models.collection-revision
   (:require [metabase.util :as u]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [date :as du]
+             [i18n :refer [tru]]]
             [toucan
              [db :as db]
              [models :as models]]))
diff --git a/src/metabase/models/field_values.clj b/src/metabase/models/field_values.clj
index 11119715cfdc81348acc941942f7b31947a09c29..fa2daf6528551137015cd2a7b34bc705a9b21a61 100644
--- a/src/metabase/models/field_values.clj
+++ b/src/metabase/models/field_values.clj
@@ -1,8 +1,9 @@
 (ns metabase.models.field-values
   (:require [clojure.tools.logging :as log]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util
+             [i18n :refer [trs]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan
              [db :as db]
@@ -63,10 +64,10 @@
   (let [total-length (reduce + (map (comp count str)
                                     distinct-values))]
     (u/prog1 (<= total-length total-max-length)
-      (log/debug (format "Field values total length is %d (max %d)." total-length total-max-length)
+      (log/debug (trs "Field values total length is {0} (max {1})." total-length total-max-length)
                  (if <>
-                   "FieldValues are allowed for this Field."
-                   "FieldValues are NOT allowed for this Field.")))))
+                   (trs "FieldValues are allowed for this Field.")
+                   (trs "FieldValues are NOT allowed for this Field."))))))
 
 
 (defn- distinct-values
@@ -197,6 +198,6 @@
     (doseq [{table-id :table_id, :as field} fields]
       (when (table-id->is-on-demand? table-id)
         (log/debug
-         (format "Field %d '%s' should have FieldValues and belongs to a Database with On-Demand FieldValues updating."
+         (trs "Field {0} ''{1}'' should have FieldValues and belongs to a Database with On-Demand FieldValues updating."
                  (u/get-id field) (:name field)))
         (create-or-update-field-values! field)))))
diff --git a/src/metabase/models/humanization.clj b/src/metabase/models/humanization.clj
index 4a83cb21e1ec1f40ba355dbb5d007e5c8a9d577b..51cbbd7c9cbebc2cdc47615bde20a594b3798f94 100644
--- a/src/metabase/models/humanization.clj
+++ b/src/metabase/models/humanization.clj
@@ -12,8 +12,9 @@
   (:require [clojure.string :as str]
             [clojure.tools.logging :as log]
             [metabase.models.setting :as setting :refer [defsetting]]
-            [metabase.util.infer-spaces :refer [infer-spaces]]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [infer-spaces :refer [infer-spaces]]]
             [toucan.db :as db]))
 
 (def ^:private ^:const acronyms
diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj
index 18d69cb696625f715a4710da3ec9307dd209c6b0..9fc5c7438d5037d0b9bd1fc67749ba250fd4f973 100644
--- a/src/metabase/models/permissions.clj
+++ b/src/metabase/models/permissions.clj
@@ -15,8 +15,8 @@
              [permissions-revision :as perms-revision :refer [PermissionsRevision]]]
             [metabase.util
              [honeysql-extensions :as hx]
+             [i18n :as ui18n :refer [tru]]
              [schema :as su]]
-            [puppetlabs.i18n.core :refer [tru]]
             [schema.core :as s]
             [toucan
              [db :as db]
@@ -79,7 +79,7 @@
   [{:keys [group_id]}]
   (when (and (= group_id (:id (group/admin)))
              (not *allow-admin-permissions-changes*))
-    (throw (ex-info (tru "You cannot create or revoke permissions for the 'Admin' group.")
+    (throw (ui18n/ex-info (tru "You cannot create or revoke permissions for the ''Admin'' group.")
              {:status-code 400}))))
 
 (defn- assert-valid-object
@@ -89,7 +89,7 @@
              (not (valid-object-path? object))
              (or (not= object "/")
                  (not *allow-root-entries*)))
-    (throw (ex-info (tru "Invalid permissions object path: ''{0}''." object)
+    (throw (ui18n/ex-info (tru "Invalid permissions object path: ''{0}''." object)
              {:status-code 400}))))
 
 (defn- assert-valid
@@ -555,8 +555,8 @@
    Return a 409 (Conflict) if the numbers don't match up."
   [old-graph new-graph]
   (when (not= (:revision old-graph) (:revision new-graph))
-    (throw (ex-info (str (tru "Looks like someone else edited the permissions and your data is out of date.")
-                         (tru "Please fetch new data and try again."))
+    (throw (ui18n/ex-info (str (tru "Looks like someone else edited the permissions and your data is out of date.")
+                               (tru "Please fetch new data and try again."))
              {:status-code 409}))))
 
 (defn- save-perms-revision!
diff --git a/src/metabase/models/permissions_group.clj b/src/metabase/models/permissions_group.clj
index 34621c2193e818af21896541cdd826710c5a13f9..bc3072e0897932bd61520057df2eda5cd9a052b0 100644
--- a/src/metabase/models/permissions_group.clj
+++ b/src/metabase/models/permissions_group.clj
@@ -10,7 +10,7 @@
             [clojure.tools.logging :as log]
             [metabase.models.setting :as setting]
             [metabase.util :as u]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util.i18n :as ui18n :refer [trs tru]]
             [toucan
              [db :as db]
              [models :as models]]))
@@ -26,8 +26,8 @@
                    :name group-name)
                  (u/prog1 (db/insert! PermissionsGroup
                             :name group-name)
-                          (log/info (u/format-color 'green (trs "Created magic permissions group ''{0}'' (ID = {1})"
-                                                                group-name (:id <>)))))))))
+                   (log/info (u/format-color 'green (trs "Created magic permissions group ''{0}'' (ID = {1})"
+                                                         group-name (:id <>)))))))))
 
 (def ^{:arglists '([])} ^metabase.models.permissions_group.PermissionsGroupInstance
   all-users
@@ -57,7 +57,7 @@
 (defn- check-name-not-already-taken
   [group-name]
   (when (exists-with-name? group-name)
-    (throw (ex-info (tru "A group with that name already exists.") {:status-code 400}))))
+    (throw (ui18n/ex-info (tru "A group with that name already exists.") {:status-code 400}))))
 
 (defn- check-not-magic-group
   "Make sure we're not trying to edit/delete one of the magic groups, or throw an exception."
@@ -67,7 +67,7 @@
                        (admin)
                        (metabot)]]
     (when (= id (:id magic-group))
-      (throw (ex-info (tru "You cannot edit or delete the ''{0}'' permissions group!" (:name magic-group))
+      (throw (ui18n/ex-info (tru "You cannot edit or delete the ''{0}'' permissions group!" (:name magic-group))
                {:status-code 400})))))
 
 
diff --git a/src/metabase/models/permissions_group_membership.clj b/src/metabase/models/permissions_group_membership.clj
index 87e90ddd1279fe43655b6c9e67ac7b626cb3a48f..83bf95456c4c91de6e0837dbcc5bc11dc71d0a59 100644
--- a/src/metabase/models/permissions_group_membership.clj
+++ b/src/metabase/models/permissions_group_membership.clj
@@ -1,7 +1,7 @@
 (ns metabase.models.permissions-group-membership
   (:require [metabase.models.permissions-group :as group]
             [metabase.util :as u]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util.i18n :as ui18n :refer [tru]]
             [toucan
              [db :as db]
              [models :as models]]))
@@ -12,8 +12,8 @@
   "Throw an Exception if we're trying to add or remove a user to the MetaBot group."
   [group-id]
   (when (= group-id (:id (group/metabot)))
-    (throw (ex-info (tru "You cannot add or remove users to/from the 'MetaBot' group.")
-                    {:status-code 400}))))
+    (throw (ui18n/ex-info (tru "You cannot add or remove users to/from the ''MetaBot'' group.")
+             {:status-code 400}))))
 
 (def ^:dynamic ^Boolean *allow-changing-all-users-group-members*
   "Should we allow people to be added to or removed from the All Users permissions group?
@@ -25,14 +25,14 @@
   [group-id]
   (when (= group-id (:id (group/all-users)))
     (when-not *allow-changing-all-users-group-members*
-      (throw (ex-info (tru "You cannot add or remove users to/from the 'All Users' group.")
+      (throw (ui18n/ex-info (tru "You cannot add or remove users to/from the ''All Users'' group.")
                {:status-code 400})))))
 
 (defn- check-not-last-admin []
   (when (<= (db/count PermissionsGroupMembership
               :group_id (:id (group/admin)))
             1)
-    (throw (ex-info (tru "You cannot remove the last member of the 'Admin' group!")
+    (throw (ui18n/ex-info (tru "You cannot remove the last member of the ''Admin'' group!")
              {:status-code 400}))))
 
 (defn- pre-delete [{:keys [group_id user_id]}]
diff --git a/src/metabase/models/permissions_revision.clj b/src/metabase/models/permissions_revision.clj
index 07ea81ff83b71bde58be7cfd7555144581b356a5..d28f3c207c904681185e050ca7164c48f569824f 100644
--- a/src/metabase/models/permissions_revision.clj
+++ b/src/metabase/models/permissions_revision.clj
@@ -1,7 +1,8 @@
 (ns metabase.models.permissions-revision
   (:require [metabase.util :as u]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [date :as du]
+             [i18n :refer [tru]]]
             [toucan
              [db :as db]
              [models :as models]]))
diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj
index 6d9816825f387beccd93e54c92ed69ab924c7b3a..d3ca3f0e4d293028e963ed6cbdc44c2221949ed0 100644
--- a/src/metabase/models/pulse.clj
+++ b/src/metabase/models/pulse.clj
@@ -27,8 +27,9 @@
              [pulse-card :refer [PulseCard]]
              [pulse-channel :as pulse-channel :refer [PulseChannel]]
              [pulse-channel-recipient :refer [PulseChannelRecipient]]]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan
              [db :as db]
diff --git a/src/metabase/models/query/permissions.clj b/src/metabase/models/query/permissions.clj
index 5bb219184acd0f3d1be96eff9b1b1efc8be2590c..e8f7f5896d8eb995c45e72ba09a077070066c296 100644
--- a/src/metabase/models/query/permissions.clj
+++ b/src/metabase/models/query/permissions.clj
@@ -8,8 +8,9 @@
              [permissions :as perms]]
             [metabase.query-processor.util :as qputil]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan.db :as db]))
 
diff --git a/src/metabase/models/query_execution.clj b/src/metabase/models/query_execution.clj
index 4fda12dd6fe27f78de5412dbf33d54592963fa05..e61d70dd73b7c2dbbbee7f0ca160d084c37afd9c 100644
--- a/src/metabase/models/query_execution.clj
+++ b/src/metabase/models/query_execution.clj
@@ -1,6 +1,6 @@
 (ns metabase.models.query-execution
   (:require [metabase.util :as u]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util.i18n :refer [tru]]
             [schema.core :as s]
             [toucan.models :as models]))
 
diff --git a/src/metabase/models/revision.clj b/src/metabase/models/revision.clj
index c79fd82e03c8938093b60915c001399394fb0430..e09f733d41f7eeabd6a98745dce2ed56f8da5464 100644
--- a/src/metabase/models/revision.clj
+++ b/src/metabase/models/revision.clj
@@ -3,8 +3,9 @@
             [metabase.models.revision.diff :refer [diff-string]]
             [metabase.models.user :refer [User]]
             [metabase.util :as u]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [date :as du]
+             [i18n :refer [tru]]]
             [toucan
              [db :as db]
              [hydrate :refer [hydrate]]
diff --git a/src/metabase/models/setting.clj b/src/metabase/models/setting.clj
index 44dc63ac360b9909a123a57e7800696dd23ed45e..2b7213bd0bace7e7e240f3378e832b8bc6392924 100644
--- a/src/metabase/models/setting.clj
+++ b/src/metabase/models/setting.clj
@@ -44,8 +44,8 @@
              [util :as u]]
             [metabase.util
              [date :as du]
-             [honeysql-extensions :as hx]]
-            [puppetlabs.i18n.core :refer [trs tru]]
+             [honeysql-extensions :as hx]
+             [i18n :as ui18n :refer [trs tru]]]
             [schema.core :as s]
             [toucan
              [db :as db]
@@ -75,7 +75,7 @@
 
 (def ^:private SettingDefinition
   {:name        s/Keyword
-   :description s/Str            ; used for docstring and is user-facing in the admin panel
+   :description s/Any            ; description is validated via the macro, not schema
    :default     s/Any
    :type        Type             ; all values are stored in DB as Strings,
    :getter      clojure.lang.IFn ; different getters/setters take care of parsing/unparsing
@@ -147,7 +147,8 @@
           (db/simple-insert! Setting :key settings-last-updated-key, :value current-timestamp-as-string-honeysql)
           (catch java.sql.SQLException e
             ;; go ahead and log the Exception anyway on the off chance that it *wasn't* just a race condition issue
-            (log/error (tru "Error inserting a new Setting:") (with-out-str (jdbc/print-sql-exception-chain e)))))))
+            (log/error (trs "Error inserting a new Setting: {0}"
+                            (with-out-str (jdbc/print-sql-exception-chain e))))))))
   ;; Now that we updated the value in the DB, go ahead and update our cached value as well, because we know about the
   ;; changes
   (swap! cache assoc settings-last-updated-key (db/select-one-field :value Setting :key settings-last-updated-key)))
@@ -176,7 +177,7 @@
                           [:> :value last-known-update]]})
         (when <>
           (log/info (u/format-color 'red
-                        (trs "Settings have been changed on another instance, and will be reloaded here.")))))))))
+                        (str (trs "Settings have been changed on another instance, and will be reloaded here."))))))))))
 
 (def ^:private cache-update-check-interval-ms
   "How often we should check whether the Settings cache is out of date (which requires a DB call)?"
@@ -459,7 +460,7 @@
   "Register a new Setting with a map of `SettingDefinition` attributes.
    This is used internally be `defsetting`; you shouldn't need to use it yourself."
   [{setting-name :name, setting-type :type, default :default, :as setting}]
-  (u/prog1 (let [setting-type (s/validate Type (or setting-type :string))]
+  (u/prog1 (let [setting-type         (s/validate Type (or setting-type :string))]
              (merge {:name        setting-name
                      :description nil
                      :type        setting-type
@@ -518,6 +519,33 @@
      ;; :refer-clojure :exclude doesn't seem to work in this case
      (metabase.models.setting/set! setting new-value))))
 
+(defn- expr-of-sym? [symbols expr]
+  (when-let [first-sym (and (coll? expr)
+                            (first expr))]
+    (some #(= first-sym %) symbols)))
+
+(defn- valid-trs-or-tru? [desc]
+  (expr-of-sym? ['trs 'tru `trs `tru] desc))
+
+(defn- valid-str-of-trs-or-tru? [maybe-str-expr]
+  (when (expr-of-sym? ['str `str] maybe-str-expr)
+    ;; When there are several i18n'd sentences, there will probably be a surrounding `str` invocation and a space in
+    ;; between the sentences, remove those to validate the i18n clauses
+    (let [exprs-without-strs (remove (every-pred string? str/blank?) (rest maybe-str-expr))]
+      ;; We should have at lease 1 i18n clause, so ensure `exprs-without-strs` is not empty
+      (and (seq exprs-without-strs)
+           (every? valid-trs-or-tru? exprs-without-strs)))))
+
+(defn- validate-description
+  "Validates the description expression `desc-expr`, ensuring it contains an i18n form, or a string consisting of 1 or more i18n forms"
+  [desc]
+  (when-not (or (valid-trs-or-tru? desc)
+                (valid-str-of-trs-or-tru? desc))
+    (throw (IllegalArgumentException.
+            (str (trs "defsetting descriptions strings must be `:internal?` or internationalized, found: `{0}`"
+                      (pr-str desc))))))
+  desc)
+
 (defmacro defsetting
   "Defines a new Setting that will be added to the DB at some point in the future.
    Conveniently can be used as a getter/setter as well:
@@ -549,9 +577,12 @@
   {:style/indent 1}
   [setting-symb description & {:as options}]
   {:pre [(symbol? setting-symb)]}
-  `(let [setting# (register-setting! (assoc ~options
+  `(let [desc# ~(if (:internal? options)
+                  description
+                  (validate-description description))
+         setting# (register-setting! (assoc ~options
                                        :name ~(keyword setting-symb)
-                                       :description ~description))]
+                                       :description desc#))]
      (-> (def ~setting-symb (setting-fn setting#))
          (alter-meta! merge (metadata-for-setting-fn setting#)))))
 
@@ -586,7 +617,7 @@
                        v)
      :is_env_setting (boolean env-value)
      :env_name       (env-var-name setting)
-     :description    (:description setting)
+     :description    (str (:description setting))
      :default        (or (when env-value
                            (format "Using $%s" (env-var-name setting)))
                          (:default setting))}))
diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj
index 858558ab24621cb0c2054f02718af6fe5428580f..6f2dd9a005130d342700741ba7bbf3ce56e622eb 100644
--- a/src/metabase/models/user.clj
+++ b/src/metabase/models/user.clj
@@ -13,8 +13,8 @@
              [permissions-group-membership :as perm-membership :refer [PermissionsGroupMembership]]]
             [metabase.util
              [date :as du]
+             [i18n :refer [tru]]
              [schema :as su]]
-            [puppetlabs.i18n.core :refer [tru]]
             [schema.core :as s]
             [toucan
              [db :as db]
diff --git a/src/metabase/plugins.clj b/src/metabase/plugins.clj
index 0ee78b8d8fdf7e4d0a30948ff1dda0292274b0e5..a7933a560ee0c73e9c57a7fe60d57c49224a8946 100644
--- a/src/metabase/plugins.clj
+++ b/src/metabase/plugins.clj
@@ -8,7 +8,7 @@
             [metabase
              [config :as config]
              [util :as u]]
-            [puppetlabs.i18n.core :refer [trs]]))
+            [metabase.util.i18n :refer [trs]]))
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                                     Java 8                                                     |
@@ -43,7 +43,7 @@
             :when (and (.isFile file)
                        (.canRead file)
                        (re-find #"\.jar$" (.getPath file)))]
-      (log/info (u/format-color 'magenta (str (trs "Loading plugin {0}... " file) (u/emoji "🔌"))))
+      (log/info (u/format-color 'magenta (trs "Loading plugin {0}... " file) (u/emoji "🔌")))
       (add-jar-to-classpath! file))))
 
 
diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj
index 643c05315696671011415f1d9ba5eb663ecabbcd..34c0c73394cd96da4e83ba4d2637c07c285fe51e 100644
--- a/src/metabase/public_settings.clj
+++ b/src/metabase/public_settings.clj
@@ -8,9 +8,8 @@
              [setting :as setting :refer [defsetting]]]
             [metabase.public-settings.metastore :as metastore]
             [metabase.util
-             [i18n :refer [available-locales-with-names set-locale]]
+             [i18n :refer [available-locales-with-names set-locale tru]]
              [password :as password]]
-            [puppetlabs.i18n.core :refer [tru]]
             [toucan.db :as db])
   (:import [java.util TimeZone UUID]))
 
diff --git a/src/metabase/public_settings/metastore.clj b/src/metabase/public_settings/metastore.clj
index ed9a6685141d5b38cbed0f96ca837ffe1944db3e..6d61738fc5df38087d987b1729eca0c7442670d5 100644
--- a/src/metabase/public_settings/metastore.clj
+++ b/src/metabase/public_settings/metastore.clj
@@ -8,8 +8,9 @@
              [config :as config]
              [util :as u]]
             [metabase.models.setting :as setting :refer [defsetting]]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [trs tru]]
+            [metabase.util
+             [i18n :refer [trs tru]]
+             [schema :as su]]
             [schema.core :as s]))
 
 (def ^:private ValidToken
@@ -48,15 +49,15 @@
                   ;; slurp will throw a FileNotFoundException for 404s, so in that case just return an appropriate
                   ;; 'Not Found' message
                   (catch java.io.FileNotFoundException e
-                    {:valid false, :status (tru "Unable to validate token.")})
+                    {:valid false, :status (str (tru "Unable to validate token."))})
                   ;; if there was any other error fetching the token, log it and return a generic message about the
                   ;; token being invalid. This message will get displayed in the Settings page in the admin panel so
                   ;; we do not want something complicated
                   (catch Throwable e
                     (log/error e (trs "Error fetching token status:"))
-                    {:valid false, :status (tru "There was an error checking whether this token was valid.")})))
+                    {:valid false, :status (str (tru "There was an error checking whether this token was valid."))})))
            fetch-token-status-timeout-ms
-           {:valid false, :status (tru "Token validation timed out.")})))
+           {:valid false, :status (str (tru "Token validation timed out."))})))
 
 (defn- check-embedding-token-is-valid* [token]
   (when (s/check ValidToken token)
diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj
index aa2d7f8d484f57821d6702b9c4748ffe6ebd1c5b..33a81d5ad66a75f5a17462074b9c36597533c182 100644
--- a/src/metabase/pulse.clj
+++ b/src/metabase/pulse.clj
@@ -13,9 +13,9 @@
              [pulse :refer [Pulse]]]
             [metabase.pulse.render :as render]
             [metabase.util
+             [i18n :refer [trs tru]]
              [ui-logic :as ui]
              [urls :as urls]]
-            [puppetlabs.i18n.core :refer [trs tru]]
             [schema.core :as s]
             [toucan.db :as db])
   (:import java.util.TimeZone
@@ -131,7 +131,7 @@
     (goal-met? alert results)
 
     :else
-    (let [^String error-text (tru "Unrecognized alert with condition ''{0}''" alert_condition)]
+    (let [^String error-text (str (tru "Unrecognized alert with condition ''{0}''" alert_condition))]
       (throw (IllegalArgumentException. error-text)))))
 
 (defmethod should-send-notification? :pulse
@@ -188,7 +188,7 @@
 
 (defmethod create-notification :default
   [_ _ {:keys [channel_type] :as channel}]
-  (let [^String ex-msg (tru "Unrecognized channel type {0}" (pr-str channel_type))]
+  (let [^String ex-msg (str (tru "Unrecognized channel type {0}" (pr-str channel_type)))]
     (throw (UnsupportedOperationException. ex-msg))))
 
 (defmulti ^:private send-notification!
diff --git a/src/metabase/pulse/color.clj b/src/metabase/pulse/color.clj
index bfe0a38e1610ac7df39e98c4fb049c320c633866..bd35e17e236190c23a897f8f951410305d562f96 100644
--- a/src/metabase/pulse/color.clj
+++ b/src/metabase/pulse/color.clj
@@ -2,7 +2,7 @@
   "Namespaces that uses the Nashorn javascript engine to invoke some shared javascript code that we use to determine
   the background color of pulse table cells"
   (:require [cheshire.core :as json]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [schema.core :as s])
   (:import java.io.InputStream
            [javax.script Invocable ScriptEngine ScriptEngineManager]
diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj
index b156769977a9025d302e46518458afe17cf89ffb..c720b8166fa77d98fb4aa461ae3d21f12397d642 100644
--- a/src/metabase/pulse/render.clj
+++ b/src/metabase/pulse/render.clj
@@ -14,9 +14,9 @@
             [metabase.util :as u]
             [metabase.util
              [date :as du]
+             [i18n :refer [trs tru]]
              [ui-logic :as ui-logic]
              [urls :as urls]]
-            [puppetlabs.i18n.core :refer [trs tru]]
             [schema.core :as s])
   (:import cz.vutbr.web.css.MediaSpec
            [java.awt BasicStroke Color Dimension RenderingHints]
@@ -548,7 +548,7 @@
                  (* 2 sparkline-dot-radius)
                  (* 2 sparkline-dot-radius)))
     (when-not (ImageIO/write image "png" os)                    ; returns `true` if successful -- see JavaDoc
-      (let [^String msg (tru "No appropriate image writer found!")]
+      (let [^String msg (str (tru "No appropriate image writer found!"))]
         (throw (Exception. msg))))
     (.toByteArray os)))
 
@@ -777,7 +777,7 @@
   [render-type timezone card {:keys [data error]}]
   (try
     (when error
-      (let [^String msg (tru "Card has errors: {0}" error)]
+      (let [^String msg (str (tru "Card has errors: {0}" error))]
         (throw (Exception. msg))))
     (case (detect-pulse-card-type card data)
       :empty     (render:empty     render-type card data)
diff --git a/src/metabase/query_processor/middleware/fetch_source_query.clj b/src/metabase/query_processor/middleware/fetch_source_query.clj
index 997d146e13c6bcdf8929272a2954c5f6cb7cfbd1..14fdcb57c6b3a39eefc02187dc54988a42f794d8 100644
--- a/src/metabase/query_processor/middleware/fetch_source_query.clj
+++ b/src/metabase/query_processor/middleware/fetch_source_query.clj
@@ -6,7 +6,7 @@
              [interface :as i]
              [util :as qputil]]
             [metabase.util :as u]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [toucan.db :as db]))
 
 (defn- trim-query
diff --git a/src/metabase/query_processor/middleware/parameters/sql.clj b/src/metabase/query_processor/middleware/parameters/sql.clj
index bfcc36768c311f99f30df3f48c49f06cf78766c2..c30e5c09034f29305ca965aa217e3a8f105d0134 100644
--- a/src/metabase/query_processor/middleware/parameters/sql.clj
+++ b/src/metabase/query_processor/middleware/parameters/sql.clj
@@ -13,8 +13,8 @@
             [metabase.query-processor.middleware.parameters.dates :as date-params]
             [metabase.util
              [date :as du]
+             [i18n :as ui18n :refer [tru]]
              [schema :as su]]
-            [puppetlabs.i18n.core :refer [tru]]
             [schema.core :as s]
             [toucan.db :as db])
   (:import clojure.lang.Keyword
@@ -497,8 +497,8 @@
         [prefix & segmented-strings] (str/split s begin-pattern)]
     (when-let [^String msg (and (seq segmented-strings)
                                 (not-every? #(str/index-of % delimited-end) segmented-strings)
-                                (tru "Found ''{0}'' with no terminating ''{1}'' in query ''{2}''"
-                                     delimited-begin delimited-end s))]
+                                (str (tru "Found ''{0}'' with no terminating ''{1}'' in query ''{2}''"
+                                          delimited-begin delimited-end s)))]
       (throw (IllegalArgumentException. msg)))
     {:prefix            prefix
      :delimited-strings (for [segmented-string segmented-strings
@@ -535,8 +535,8 @@
   [s param-key->value]
   (let [results (parse-params s param-key->value)]
     (if-let [{:keys [param-key]} (m/find-first no-value-param? results)]
-      (throw (ex-info (tru "Unable to substitute ''{0}'': param not specified.\nFound: {1}"
-                           (name param-key) (pr-str (map name (keys param-key->value))))
+      (throw (ui18n/ex-info (tru "Unable to substitute ''{0}'': param not specified.\nFound: {1}"
+                                 (name param-key) (pr-str (map name (keys param-key->value))))
                {:status-code 400}))
       results)))
 
diff --git a/src/metabase/query_processor/middleware/permissions.clj b/src/metabase/query_processor/middleware/permissions.clj
index 931bd91917b1d8d4820ecface7e9bdd50babc0cd..3fc51fb0f1ee42ff7cb7b02a3d7889e07c066c7b 100644
--- a/src/metabase/query_processor/middleware/permissions.clj
+++ b/src/metabase/query_processor/middleware/permissions.clj
@@ -6,8 +6,9 @@
              [interface :as mi]
              [permissions :as perms]]
             [metabase.models.query.permissions :as query-perms]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util
+             [i18n :refer [tru]]
+             [schema :as su]]
             [schema.core :as s]
             [toucan.db :as db]))
 
diff --git a/src/metabase/routes.clj b/src/metabase/routes.clj
index e1f76ef64c632ef0fde256d30ed11808a4db4665..f0edc0493f2b92f15b26ab204f68f81eb0bd112f 100644
--- a/src/metabase/routes.clj
+++ b/src/metabase/routes.clj
@@ -16,7 +16,7 @@
              [routes :as api]]
             [metabase.core.initialization-status :as init-status]
             [metabase.util.embed :as embed]
-            [puppetlabs.i18n.core :refer [trs *locale*]]
+            [puppetlabs.i18n.core :refer [*locale*]]
             [ring.util.response :as resp]
             [stencil.core :as stencil]))
 
diff --git a/src/metabase/sync/analyze.clj b/src/metabase/sync/analyze.clj
index dc2a298bd4fce60430ff6f5ab2cd53d74f57a9c2..9c249104b3a484e890e5d154f8a9af317d32e0cd 100644
--- a/src/metabase/sync/analyze.clj
+++ b/src/metabase/sync/analyze.clj
@@ -13,8 +13,9 @@
              [fingerprint :as fingerprint]
              #_[table-row-count :as table-row-count]]
             [metabase.util :as u]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util
+             [date :as du]
+             [i18n :refer [trs]]]
             [schema.core :as s]
             [toucan.db :as db]))
 
diff --git a/src/metabase/sync/analyze/fingerprint/fingerprinters.clj b/src/metabase/sync/analyze/fingerprint/fingerprinters.clj
index 867a0f9104cca58594ca37b8f4ddf7be744fedb1..b77744c26f933bef6506fcdca6300bc030262719 100644
--- a/src/metabase/sync/analyze/fingerprint/fingerprinters.clj
+++ b/src/metabase/sync/analyze/fingerprint/fingerprinters.clj
@@ -7,8 +7,9 @@
             [metabase.sync.analyze.classifiers.name :as classify.name]
             [metabase.sync.util :as sync-util]
             [metabase.util :as u]
-            [metabase.util.date :as du]
-            [puppetlabs.i18n.core :as i18n :refer [trs]]
+            [metabase.util
+             [date :as du]
+             [i18n :refer [trs]]]
             [redux.core :as redux])
   (:import com.clearspring.analytics.stream.cardinality.HyperLogLogPlus
            org.joda.time.DateTime))
@@ -123,7 +124,7 @@
             ~transducer
             (fn [fingerprint#]
               {:type {~(first field-type) fingerprint#}})))
-         (trs "Error generating fingerprint for {0}" (sync-util/name-for-logging field#))))))
+         (str (trs "Error generating fingerprint for {0}" (sync-util/name-for-logging field#)))))))
 
 (defn- earliest
   ([] (java.util.Date. Long/MAX_VALUE))
diff --git a/src/metabase/sync/field_values.clj b/src/metabase/sync/field_values.clj
index b27d6844879a4a42605545cb10443eec491384d0..24b3c6f8366951647e682d89f81708b475193423 100644
--- a/src/metabase/sync/field_values.clj
+++ b/src/metabase/sync/field_values.clj
@@ -8,7 +8,7 @@
              [interface :as i]
              [util :as sync-util]]
             [metabase.util :as u]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [schema.core :as s]
             [toucan.db :as db]))
 
diff --git a/src/metabase/sync/sync_metadata.clj b/src/metabase/sync/sync_metadata.clj
index ff191a50b30fffcfd1937864a103a0f34f652c09..a610f00fd21a6c8343a8568813a63528c56e2abe 100644
--- a/src/metabase/sync/sync_metadata.clj
+++ b/src/metabase/sync/sync_metadata.clj
@@ -15,7 +15,7 @@
              [metabase-metadata :as metabase-metadata]
              [sync-timezone :as sync-tz]
              [tables :as sync-tables]]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [schema.core :as s]))
 
 (defn- sync-fields-summary [{:keys [total-fields updated-fields] :as step-info}]
diff --git a/src/metabase/sync/util.clj b/src/metabase/sync/util.clj
index b77095bf988be256f7693b8402c6a393988dd24b..40d9a4796cd9c23305ab45ce518cd6fa97843f76 100644
--- a/src/metabase/sync/util.clj
+++ b/src/metabase/sync/util.clj
@@ -20,8 +20,8 @@
             [metabase.sync.interface :as i]
             [metabase.util
              [date :as du]
+             [i18n :refer [trs]]
              [schema :as su]]
-            [puppetlabs.i18n.core :refer [trs]]
             [ring.util.codec :as codec]
             [schema.core :as s]
             [taoensso.nippy :as nippy]
@@ -262,19 +262,19 @@
 (extend-protocol INameForLogging
   i/DatabaseInstance
   (name-for-logging [{database-name :name, id :id, engine :engine,}]
-    (trs "{0} Database {1} ''{2}''" (name engine) (or id "") database-name))
+    (str (trs "{0} Database {1} ''{2}''" (name engine) (or id "") database-name)))
 
   i/TableInstance
   (name-for-logging [{schema :schema, id :id, table-name :name}]
-    (trs "Table {0} ''{1}''" (or id "") (str (when (seq schema) (str schema ".")) table-name)))
+    (str (trs "Table {0} ''{1}''" (or id "") (str (when (seq schema) (str schema ".")) table-name))))
 
   i/FieldInstance
   (name-for-logging [{field-name :name, id :id}]
-    (trs "Field {0} ''{1}''" (or id "") field-name))
+    (str (trs "Field {0} ''{1}''" (or id "") field-name)))
 
   i/ResultColumnMetadataInstance
   (name-for-logging [{field-name :name}]
-    (trs "Field ''{0}''" field-name)))
+    (str (trs "Field ''{0}''" field-name))))
 
 (defn calculate-hash
   "Calculate a cryptographic hash on `clj-data` and return that hash as a string"
@@ -338,7 +338,7 @@
   ([step-name sync-fn log-summary-fn]
    {:sync-fn        sync-fn
     :step-name      step-name
-    :log-summary-fn log-summary-fn}))
+    :log-summary-fn (comp str log-summary-fn)}))
 
 (defn- datetime->str [datetime]
   (du/->iso-8601-datetime datetime "UTC"))
@@ -348,9 +348,9 @@
   [database :- i/DatabaseInstance
    {:keys [step-name sync-fn log-summary-fn] :as step} :- StepDefinition]
   (let [start-time (time/now)
-        results    (with-start-and-finish-debug-logging (trs "step ''{0}'' for {1}"
-                                                             step-name
-                                                             (name-for-logging database))
+        results    (with-start-and-finish-debug-logging (str (trs "step ''{0}'' for {1}"
+                                                                  step-name
+                                                                  (name-for-logging database)))
                      #(sync-fn database))
         end-time   (time/now)]
     [step-name (assoc results
@@ -367,28 +367,28 @@
   ;; call. Constructing the log below requires some work, no need to incur that cost debug logging isn't enabled
   (log/debug
    (str
-    (format
+    (apply format
      (str "\n#################################################################\n"
           "# %s\n"
           "# %s\n"
           "# %s\n"
           "# %s\n")
-     (trs "Completed {0} on {1}" operation (:name database))
-     (trs "Start: {0}" (datetime->str start-time))
-     (trs "End: {0}" (datetime->str end-time))
-     (trs "Duration: {0}" (calculate-duration-str start-time end-time)))
-    (apply str (for [[step-name {:keys [start-time end-time log-summary-fn] :as step-info}] steps]
-                 (format (str "# ---------------------------------------------------------------\n"
+     (map str [(trs "Completed {0} on {1}" operation (:name database))
+               (trs "Start: {0}" (datetime->str start-time))
+               (trs "End: {0}" (datetime->str end-time))
+               (trs "Duration: {0}" (calculate-duration-str start-time end-time))]))
+    (apply str (for [[step-name {:keys [start-time end-time duration log-summary-fn] :as step-info}] steps]
+                 (apply format (str "# ---------------------------------------------------------------\n"
                               "# %s\n"
                               "# %s\n"
                               "# %s\n"
                               "# %s\n"
                               (when log-summary-fn
                                 (format "# %s\n" (log-summary-fn step-info))))
-                         (trs "Completed step ''{0}''" step-name)
-                         (trs "Start: {0}" (datetime->str start-time))
-                         (trs "End: {0}" (datetime->str end-time))
-                         (trs "Duration: {0}" (calculate-duration-str start-time end-time)))))
+                        (map str [(trs "Completed step ''{0}''" step-name)
+                                  (trs "Start: {0}" (datetime->str start-time))
+                                  (trs "End: {0}" (datetime->str end-time))
+                                  (trs "Duration: {0}" (calculate-duration-str start-time end-time))]))))
     "#################################################################\n")))
 
 (def ^:private SyncOperationOrStepRunMetadata
diff --git a/src/metabase/task.clj b/src/metabase/task.clj
index f3e475749d4627249e1ef052eb4d5069f0931c3e..2bb14b8dc1d1b328f12e931e099abbf49c785059 100644
--- a/src/metabase/task.clj
+++ b/src/metabase/task.clj
@@ -12,7 +12,7 @@
             [metabase
              [db :as mdb]
              [util :as u]]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [schema.core :as s])
   (:import [org.quartz JobDetail JobKey Scheduler Trigger TriggerKey]))
 
diff --git a/src/metabase/util.clj b/src/metabase/util.clj
index df109aeb32100f203fa16fb26b56754e01d3951d..7f99e67b89e9287e65004158e82331e1aaa1e7aa 100644
--- a/src/metabase/util.clj
+++ b/src/metabase/util.clj
@@ -10,7 +10,7 @@
             [clojure.tools.namespace.find :as ns-find]
             [colorize.core :as colorize]
             [metabase.config :as config]
-            [puppetlabs.i18n.core :as i18n :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [ring.util.codec :as codec])
   (:import [java.net InetAddress InetSocketAddress Socket]
            [java.text Normalizer Normalizer$Form]))
diff --git a/src/metabase/util/date.clj b/src/metabase/util/date.clj
index 7a84803ba18e08ee317ac3f965c6c3d57c188409..31e2ef008d1a8e0ca4aeb1a4c5a4024af9b6e683 100644
--- a/src/metabase/util/date.clj
+++ b/src/metabase/util/date.clj
@@ -7,8 +7,9 @@
             [clojure.math.numeric-tower :as math]
             [clojure.tools.logging :as log]
             [metabase.util :as u]
-            [metabase.util.schema :as su]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util
+             [i18n :refer [trs]]
+             [schema :as su]]
             [schema.core :as s])
   (:import clojure.lang.Keyword
            [java.sql Time Timestamp]
diff --git a/src/metabase/util/embed.clj b/src/metabase/util/embed.clj
index d38571bd20f9ae644853635fae04ccec71a3c375..51ec4130c6bca09cb1dbf8f08800554ae554b2d1 100644
--- a/src/metabase/util/embed.clj
+++ b/src/metabase/util/embed.clj
@@ -9,7 +9,7 @@
             [hiccup.core :refer [html]]
             [metabase.models.setting :as setting]
             [metabase.public-settings :as public-settings]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util.i18n :refer [tru]]
             [ring.util.codec :as codec]))
 
 ;;; ------------------------------------------------------------ PUBLIC LINKS UTIL FNS ------------------------------------------------------------
diff --git a/src/metabase/util/encryption.clj b/src/metabase/util/encryption.clj
index 9962adad8af5317d1babbbeab1fcd022bc5c2b59..24afed693af1f7f3cfdfbfdcb111df797bbfa0f6 100644
--- a/src/metabase/util/encryption.clj
+++ b/src/metabase/util/encryption.clj
@@ -10,7 +10,7 @@
             [clojure.tools.logging :as log]
             [environ.core :as env]
             [metabase.util :as u]
-            [puppetlabs.i18n.core :refer [trs]]
+            [metabase.util.i18n :refer [trs]]
             [ring.util.codec :as codec]))
 
 (defn secret-key->hash
@@ -36,7 +36,8 @@
    (trs "Saved credentials encryption is ENABLED for this Metabase instance.")
    (trs "Saved credentials encryption is DISABLED for this Metabase instance."))
  (u/emoji (if default-secret-key "🔐" "🔓"))
- (trs "\nFor more information, see")
+ "\n"
+ (trs "For more information, see")
  "https://www.metabase.com/docs/latest/operations-guide/start.html#encrypting-your-database-connection-details-at-rest")
 
 (defn encrypt
diff --git a/src/metabase/util/i18n.clj b/src/metabase/util/i18n.clj
index dd7a4d5768a46d8d6f8aadbd5a95e286c9dd33e3..7302c9c993e79e7a305209391d2a4dc6f3073730 100644
--- a/src/metabase/util/i18n.clj
+++ b/src/metabase/util/i18n.clj
@@ -1,6 +1,9 @@
 (ns metabase.util.i18n
-  (:require
-    [puppetlabs.i18n.core :refer [available-locales]])
+  (:refer-clojure :exclude [ex-info])
+  (:require [cheshire.generate :as json-gen]
+            [clojure.walk :as walk]
+            [puppetlabs.i18n.core :as i18n :refer [available-locales]]
+            [schema.core :as s])
   (:import java.util.Locale))
 
 (defn available-locales-with-names
@@ -12,3 +15,75 @@
   "This sets the local for the instance"
   [locale]
   (Locale/setDefault (Locale/forLanguageTag locale)))
+
+(defrecord UserLocalizedString [ns-str msg args]
+  java.lang.Object
+  (toString [_]
+    (apply i18n/translate ns-str (i18n/user-locale) msg args))
+  schema.core.Schema
+  (explain [this]
+    (str this)))
+
+(defrecord SystemLocalizedString [ns-str msg args]
+  java.lang.Object
+  (toString [_]
+    (apply i18n/translate ns-str (i18n/system-locale) msg args))
+  s/Schema
+  (explain [this]
+    (str this)))
+
+(defn- localized-to-json
+  "Write a UserLocalizedString or SystemLocalizedString to the `json-generator`. This is intended for
+  `json-gen/add-encoder`. Ideallys we'd implement those protocols directly as it's faster, but currently that doesn't
+  work with Cheshire"
+  [localized-string json-generator]
+  (json-gen/write-string json-generator (str localized-string)))
+
+(json-gen/add-encoder UserLocalizedString localized-to-json)
+(json-gen/add-encoder SystemLocalizedString localized-to-json)
+
+(def LocalizedString
+  "Schema for user and system localized string instances"
+  (s/cond-pre UserLocalizedString SystemLocalizedString))
+
+(defmacro tru
+  "Similar to `puppetlabs.i18n.core/tru` but creates a `UserLocalizedString` instance so that conversion to the
+  correct locale can be delayed until it is needed. The user locale comes from the browser, so conversion to the
+  localized string needs to be 'late bound' and only occur when the user's locale is in scope. Calling `str` on the
+  results of this invocation will lookup the translated version of the string."
+  [msg & args]
+  `(UserLocalizedString. (namespace-munge *ns*) ~msg ~(vec args)))
+
+(defmacro trs
+  "Similar to `puppetlabs.i18n.core/trs` but creates a `SystemLocalizedString` instance so that conversion to the
+  correct locale can be delayed until it is needed. This is needed as the system locale from the JVM can be
+  overridden/changed by a setting. Calling `str` on the results of this invocation will lookup the translated version
+  of the string."
+  [msg & args]
+  `(SystemLocalizedString. (namespace-munge *ns*) ~msg ~(vec args)))
+
+(def ^:private localized-string-checker
+  "Compiled checker for `LocalizedString`s which is more efficient when used repeatedly like in `localized-string?`
+  below"
+  (s/checker LocalizedString))
+
+(defn localized-string?
+  "Returns `true` if `maybe-a-localized-string` is a system or user localized string instance"
+  [maybe-a-localized-string]
+  (not (localized-string-checker maybe-a-localized-string)))
+
+(defn localized-strings->strings
+  "Walks the datastructure `x` and converts any localized strings to regular string"
+  [x]
+  (walk/postwalk (fn [node]
+                   (if (localized-string? node)
+                     (str node)
+                     node)) x))
+
+(defn ex-info
+  "Just like `clojure.core/ex-info` but it is i18n-aware. It will call `str` on `msg` and walk `ex-data` converting any
+  `SystemLocalizedString` and `UserLocalizedString`s to a regular string"
+  ([msg ex-data-map]
+   (clojure.core/ex-info (str msg) (localized-strings->strings ex-data-map)))
+  ([msg ex-data-map cause]
+   (clojure.core/ex-info (str msg) (localized-strings->strings ex-data-map) cause)))
diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj
index 7e84cbc09db4cd144523e6b283a5a0f81b2f35ec..ae3de5cc8dfc672ed0daec0a2fdea176130587f2 100644
--- a/src/metabase/util/schema.clj
+++ b/src/metabase/util/schema.clj
@@ -5,7 +5,7 @@
             [medley.core :as m]
             [metabase.util :as u]
             [metabase.util.password :as password]
-            [puppetlabs.i18n.core :refer [tru]]
+            [metabase.util.i18n :refer [tru]]
             [schema.core :as s]))
 
 ;; always validate all schemas in s/defn function declarations. See
diff --git a/test/metabase/api/table_test.clj b/test/metabase/api/table_test.clj
index d2b67f857a0192c013386c9730f6da873cb67326..9b24e35a4c63f6269f7544b976730f202361200a 100644
--- a/test/metabase/api/table_test.clj
+++ b/test/metabase/api/table_test.clj
@@ -165,6 +165,7 @@
 (defn- default-dimension-options []
   (->> #'table-api/dimension-options-for-response
        var-get
+       (m/map-vals #(update % :name str))
        walk/keywordize-keys))
 
 (defn- query-metadata-defaults []
diff --git a/test/metabase/models/setting_test.clj b/test/metabase/models/setting_test.clj
index 6474e20c67236e392d84e3050f9a7a292cd7bd8c..006752d48d590058e6051c4f5769ea8f13f4dc34 100644
--- a/test/metabase/models/setting_test.clj
+++ b/test/metabase/models/setting_test.clj
@@ -9,8 +9,9 @@
             [metabase.test.util :refer :all]
             [metabase.util
              [encryption :as encryption]
-             [encryption-test :as encryption-test]]
-            [puppetlabs.i18n.core :refer [tru]]
+             [encryption-test :as encryption-test]
+             [i18n :refer [tru]]]
+            [puppetlabs.i18n.core :as i18n]
             [toucan.db :as db]))
 
 ;; ## TEST SETTINGS DEFINITIONS
@@ -19,21 +20,24 @@
 ;; these tests will fail. FIXME
 
 (defsetting test-setting-1
-  "Test setting - this only shows up in dev (1)")
+  "Test setting - this only shows up in dev (1)"
+  :internal? true)
 
 (defsetting test-setting-2
   "Test setting - this only shows up in dev (2)"
+  :internal? true
   :default "[Default Value]")
 
 (defsetting ^:private test-boolean-setting
   "Test setting - this only shows up in dev (3)"
+  :internal? true
   :type :boolean)
 
 (defsetting ^:private test-json-setting
   "Test setting - this only shows up in dev (4)"
+  :internal? true
   :type :json)
 
-
 ;; ## HELPER FUNCTIONS
 
 (defn db-fetch-setting
@@ -215,10 +219,13 @@
 
 ;; Validate setting description with i18n string
 (expect
-  ["Test setting - with i18n"]
-  (for [{:keys [key description]} (setting/all)
-        :when (= :test-i18n-setting key)]
-    description))
+  ["TEST SETTING - WITH I18N"]
+  (let [zz (i18n/string-as-locale "zz")]
+    (i18n/with-user-locale zz
+      (doall
+       (for [{:keys [key description]} (setting/all)
+             :when (= :test-i18n-setting key)]
+         description)))))
 
 
 ;;; ------------------------------------------------ BOOLEAN SETTINGS ------------------------------------------------
@@ -285,7 +292,8 @@
 ;; (#4178)
 
 (setting/defsetting ^:private toucan-name
-  "Name for the Metabase Toucan mascot.")
+  "Name for the Metabase Toucan mascot."
+  :internal? true)
 
 (expect
   "Banana Beak"
@@ -339,6 +347,7 @@
 
 (defsetting ^:private test-timestamp-setting
   "Test timestamp setting"
+  :internal? true
   :type :timestamp)
 
 (expect
@@ -479,6 +488,7 @@
 
 (defsetting ^:private uncached-setting
   "A test setting that should *not* be cached."
+  :internal? true
   :cache? false)
 
 ;; make sure uncached setting still saves to the DB
diff --git a/test/metabase/publc_settings_test.clj b/test/metabase/publc_settings_test.clj
index 22249289c1791d4f04416d1b30683b8aa2314cc3..fc7f82c6bc135ebb8ec450422823a4a88e87ff4a 100644
--- a/test/metabase/publc_settings_test.clj
+++ b/test/metabase/publc_settings_test.clj
@@ -1,7 +1,8 @@
 (ns metabase.publc-settings-test
   (:require [expectations :refer :all]
             [metabase.public-settings :as public-settings]
-            [metabase.test.util :as tu]))
+            [metabase.test.util :as tu]
+            [puppetlabs.i18n.core :as i18n :refer [tru trs]]))
 
  ;; double-check that setting the `site-url` setting will automatically strip off trailing slashes
 (expect
@@ -35,3 +36,16 @@
   (tu/with-temporary-setting-values [site-url nil]
     (public-settings/site-url "https://localhost:3000")
     (public-settings/site-url)))
+
+(expect
+  "HOST"
+  (let [zz (i18n/string-as-locale "zz")]
+    (i18n/with-user-locale zz
+      (str (:display-name (first (get-in (public-settings/public-settings) [:engines :postgres :details-fields])))))))
+
+(expect
+  [true "HOST"]
+  (let [zz (i18n/string-as-locale "zz")]
+    (i18n/with-user-locale zz
+      [(= zz (i18n/user-locale))
+       (tru "Host")])))
diff --git a/test/metabase/util/schema_test.clj b/test/metabase/util/schema_test.clj
index ec063f7b779b74c68cfaca4bcf0aa609c0cc3e4b..37cad63c68b97b15bc5fd67c1d006db57a888663 100644
--- a/test/metabase/util/schema_test.clj
+++ b/test/metabase/util/schema_test.clj
@@ -4,6 +4,7 @@
             [expectations :refer :all]
             [metabase.api.common :as api]
             [metabase.util.schema :as su]
+            [puppetlabs.i18n.core :as i18n]
             [schema.core :as s]))
 
 ;; check that the API error message generation is working as intended
@@ -11,7 +12,7 @@
   (str "value may be nil, or if non-nil, value must satisfy one of the following requirements: "
        "1) value must be a boolean. "
        "2) value must be a valid boolean string ('true' or 'false').")
-  (su/api-error-message (s/maybe (s/cond-pre s/Bool su/BooleanString))))
+  (str (su/api-error-message (s/maybe (s/cond-pre s/Bool su/BooleanString)))))
 
 ;; check that API error message respects `api-param` when specified
 (api/defendpoint POST "/:id/dimension"
@@ -34,3 +35,15 @@
        "\n"
        "*  **`dimension-name`** value must be a non-blank string.")
   (:doc (meta #'POST_:id_dimension)))
+
+(defn- ex-info-msg [f]
+  (try
+    (f)
+    (catch clojure.lang.ExceptionInfo e
+      (.getMessage e))))
+
+(expect
+  #"INTEGER GREATER THAN ZERO"
+  (let [zz (i18n/string-as-locale "zz")]
+    (i18n/with-user-locale zz
+      (ex-info-msg #(s/validate su/IntGreaterThanZero -1)))))
diff --git a/test_resources/locales.clj b/test_resources/locales.clj
new file mode 100644
index 0000000000000000000000000000000000000000..7b4ba58c4e21af427388e9f0bba6ea4668d5aa92
--- /dev/null
+++ b/test_resources/locales.clj
@@ -0,0 +1,5 @@
+{
+  :locales  #{"en" "zz"}
+  :packages ["metabase"]
+  :bundle   "metabase.Messages"
+}
diff --git a/test_resources/metabase/Messages_zz.properties b/test_resources/metabase/Messages_zz.properties
new file mode 100644
index 0000000000000000000000000000000000000000..7f910db401b5fd17927fc530179bceec5e622103
--- /dev/null
+++ b/test_resources/metabase/Messages_zz.properties
@@ -0,0 +1,4 @@
+Host=HOST
+Integer\ greater\ than\ zero=INTEGER\ GREATER\ THAN ZERO
+value\ must\ be\ an\ integer\ greater\ than\ zero.=VALUE\ MUST\ BE\ AN\ INTEGER\ GREATER\ THAN\ ZERO.
+Test\ setting\ -\ with\ i18n=TEST\ SETTING\ -\ WITH\ I18N
\ No newline at end of file