diff --git a/modules/drivers/bigquery/src/metabase/driver/bigquery.clj b/modules/drivers/bigquery/src/metabase/driver/bigquery.clj
index 46318ece248170ea8a1db3f86ace65f082d2c8d0..532bae1750fd218f2f34593de9dfa47bbcfa17e7 100644
--- a/modules/drivers/bigquery/src/metabase/driver/bigquery.clj
+++ b/modules/drivers/bigquery/src/metabase/driver/bigquery.clj
@@ -335,7 +335,7 @@
   [honeysql-form :- su/Map]
   (let [[sql & args] (sql.qp/honeysql-form->sql+args :bigquery honeysql-form)]
     (when (seq args)
-      (throw (Exception. (str (tru "BigQuery statements can''t be parameterized!")))))
+      (throw (Exception. (tru "BigQuery statements can''t be parameterized!"))))
     sql))
 
 ;; From the dox: Fields must contain only letters, numbers, and underscores, start with a letter or underscore, and be
diff --git a/modules/drivers/druid/src/metabase/driver/druid.clj b/modules/drivers/druid/src/metabase/driver/druid.clj
index 85a43d97b52e17dc874e3501eda83575e5c8e08e..1094f0224c1ecefdd4dd778100e937f84f2919f7 100644
--- a/modules/drivers/druid/src/metabase/driver/druid.clj
+++ b/modules/drivers/druid/src/metabase/driver/druid.clj
@@ -35,11 +35,11 @@
                                 (:body options) (update :body json/generate-string))
         {:keys [status body]} (request-fn url options)]
     (when (not= status 200)
-      (throw (Exception. (str (tru "Error [{0}]: {1}" status body)))))
+      (throw (Exception. (tru "Error [{0}]: {1}" status body))))
     (try
       (json/parse-string body keyword)
       (catch Throwable _
-        (throw (Exception. (str (tru "Failed to parse body: {0}" body))))))))
+        (throw (Exception. (tru "Failed to parse body: {0}" body)))))))
 
 (def ^:private ^{:arglists '([url & {:as options}])} GET  (partial do-request http/get))
 (def ^:private ^{:arglists '([url & {:as options}])} POST (partial do-request http/post))
diff --git a/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj b/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj
index d48440140bafccdc1b246f4cbf94f084291d2581..b5c08a04ee1f26b904e046e8b4e85d540198a47a 100644
--- a/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj
+++ b/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj
@@ -649,7 +649,7 @@
 
                       ;; we should never get here unless our code is B U S T E D
                       _
-                      (throw (ex-info (str (tru "Expected :aggregation-options, constant, or expression."))
+                      (throw (ex-info (tru "Expected :aggregation-options, constant, or expression.")
                                {:type :bug, :input arg})))))}))
 
 
diff --git a/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics.clj b/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics.clj
index 880a73f83ee95aa76059f0f35d848a02a8538ff0..56eb046dc21d27af22975992e7ca46a1f2f6294d 100644
--- a/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics.clj
+++ b/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics.clj
@@ -229,8 +229,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)]
-    (str (tru "You must enable the Google Analytics API. Use this link to go to the Google Developers Console: {0}"
-              enable-api-url))
+    (tru "You must enable the Google Analytics API. Use this link to go to the Google Developers Console: {0}"
+         enable-api-url)
     message))
 
 (defmethod driver/mbql->native :googleanalytics [_ query]
diff --git a/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics/query_processor.clj b/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics/query_processor.clj
index 4026997ea086551f6460a3129013b1986012f9d1..c145077021cd9e407fdadfe9dca4fc125fc5db73 100644
--- a/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics/query_processor.clj
+++ b/modules/drivers/googleanalytics/src/metabase/driver/googleanalytics/query_processor.clj
@@ -7,7 +7,7 @@
             [metabase.query-processor.store :as qp.store]
             [metabase.util
              [date :as du]
-             [i18n :as ui18n :refer [tru]]
+             [i18n :as ui18n :refer [deferred-tru tru]]
              [schema :as su]]
             [schema.core :as s])
   (:import [com.google.api.services.analytics.model GaData GaData$ColumnHeaders]))
@@ -231,12 +231,12 @@
 (defn- maybe-get-only-filter-or-throw [filters]
   (when-let [filters (seq (filter some? filters))]
     (when (> (count filters) 1)
-      (throw (Exception. (str (tru "Multiple date filters are not supported")))))
+      (throw (Exception. (tru "Multiple date filters are not supported"))))
     (first filters)))
 
 (defn- try-reduce-filters [[filter1 filter2]]
   (merge-with
-    (fn [_ _] (throw (Exception. (str (tru "Multiple date filters are not supported in filters: ") filter1 filter2))))
+    (fn [_ _] (throw (Exception. (str (deferred-tru "Multiple date filters are not supported in filters: ") filter1 filter2))))
     filter1 filter2))
 
 (defmethod parse-filter:interval :and [[_ & subclauses]]
@@ -249,7 +249,7 @@
   (maybe-get-only-filter-or-throw (map parse-filter:interval subclauses)))
 
 (defmethod parse-filter:interval :not [[& _]]
-  (throw (Exception. (str (tru ":not is not yet implemented")))))
+  (throw (Exception. (tru ":not is not yet implemented"))))
 
 (defn- remove-non-datetime-filter-clauses
   "Replace any filter clauses that operate on a non-datetime Field with `nil`."
@@ -295,7 +295,7 @@
   [{filter-clause :filter}]
   (let [segments (mbql.u/match filter-clause [:segment (segment-name :guard mbql.u/ga-id?)] segment-name)]
     (when (> (count segments) 1)
-      (throw (Exception. (str (tru "Only one Google Analytics segment allowed at a time.")))))
+      (throw (Exception. (tru "Only one Google Analytics segment allowed at a time."))))
     (first segments)))
 
 (defn- handle-filter:built-in-segment
diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj
index b1b809dc0a7805b3dfe14b4274d9de108e418995..c4922ffa10cfa1866b715776ac351e2302cfccc3 100644
--- a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj
+++ b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj
@@ -21,7 +21,7 @@
             [metabase.util :as u]
             [metabase.util
              [date :as du]
-             [i18n :as ui18n :refer [tru]]
+             [i18n :as ui18n :refer [deferred-tru tru]]
              [schema :as su]]
             [monger
              [collection :as mc]
@@ -90,7 +90,7 @@
 
 (defn- log-aggregation-pipeline [form]
   (when-not i/*disable-qp-logging*
-    (log/debug (u/format-color 'green (str "\n" (tru "MONGO AGGREGATION PIPELINE:") "\n%s\n")
+    (log/debug (u/format-color 'green (str "\n" (deferred-tru "MONGO AGGREGATION PIPELINE:") "\n%s\n")
                  (->> form
                       ;; strip namespace qualifiers from Monger form
                       (walk/postwalk #(if (symbol? %) (symbol (name %)) %))
@@ -428,7 +428,7 @@
 
     :else
     (throw
-     (ex-info (str (tru "Don't know how to handle aggregation {0}" ag))
+     (ex-info (tru "Don't know how to handle aggregation {0}" ag)
        {:type :invalid-query, :clause ag}))))
 
 (defn- unwrap-named-ag [[ag-type arg :as ag]]
@@ -736,7 +736,7 @@
           actual-cols     (set (keys (first results)))
           not-in-expected (set/difference actual-cols expected-cols)]
       (when (seq not-in-expected)
-        (throw (Exception. (str (tru "Unexpected columns in results: {0}" (sort not-in-expected)))))))))
+        (throw (Exception. (tru "Unexpected columns in results: {0}" (sort not-in-expected))))))))
 
 (defn execute-query
   "Process and run a native MongoDB query."
diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/util.clj b/modules/drivers/mongo/src/metabase/driver/mongo/util.clj
index a24efe918a84265078cc7f6a68196c018c4f0ee0..de63095f15afc446a77fd8810707aa7f219bad9d 100644
--- a/modules/drivers/mongo/src/metabase/driver/mongo/util.clj
+++ b/modules/drivers/mongo/src/metabase/driver/mongo/util.clj
@@ -131,7 +131,7 @@
    Docs to generate URI string: https://docs.mongodb.com/manual/reference/connection-string/#dns-seedlist-connection-format"
   [{:keys [host port user authdb pass dbname ssl additional-options]}]
   (if-not (fqdn? host)
-    (throw (ex-info (str (tru "Using DNS SRV requires a FQDN for host" ))
+    (throw (ex-info (tru "Using DNS SRV requires a FQDN for host")
                     {:host host}))
     (let [conn-opts (connection-options-builder :ssl? ssl, :additional-options additional-options)
           authdb (if (seq authdb)
@@ -177,7 +177,7 @@
   (let [mongo-client (MongoClient. uri)]
     (if-let [db-name (.getDatabase uri)]
       [mongo-client (.getDB mongo-client db-name)]
-      (throw (ex-info (str (tru "No database name specified in URI. Monger requires a database to be explicitly configured." ))
+      (throw (ex-info (tru "No database name specified in URI. Monger requires a database to be explicitly configured.")
                       {:hosts (-> uri .getHosts)
                        :uri   (-> uri .getURI)
                        :opts  (-> uri .getOptions)})))))
diff --git a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj
index 3735632c876657cbbc2f5632e433730ae11219d0..c7da0d3c2f5e3adee41aaa9a992da6fdd176dfbe 100644
--- a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj
+++ b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj
@@ -134,7 +134,7 @@
   [{details :details}]
   (or (:db details)
       (:dbname details)
-      (throw (Exception. (str (tru "Invalid Snowflake connection details: missing DB name."))))))
+      (throw (Exception. (tru "Invalid Snowflake connection details: missing DB name.")))))
 
 (defn- query-db-name []
   ;; the store is always initialized when running QP queries; for some stuff like the test extensions DDL statements
diff --git a/src/metabase/api/automagic_dashboards.clj b/src/metabase/api/automagic_dashboards.clj
index d06719c20b9620cef8a37c2e3020319ea781bd27..4e2aa4d3b60114b4733a3237cfa6946c6badd68e 100644
--- a/src/metabase/api/automagic_dashboards.clj
+++ b/src/metabase/api/automagic_dashboards.clj
@@ -22,20 +22,20 @@
              [dashboard :as transform.dashboard]
              [materialize :as transform.materialize]]
             [metabase.util
-             [i18n :refer [tru]]
+             [i18n :refer [deferred-tru]]
              [schema :as su]]
             [ring.util.codec :as codec]
             [schema.core :as s]))
 
 (def ^:private Show
   (su/with-api-error-message (s/maybe (s/enum "all"))
-    (tru "invalid show value")))
+    (deferred-tru "invalid show value")))
 
 (def ^:private Prefix
   (su/with-api-error-message
       (s/pred (fn [prefix]
                 (some #(not-empty (rules/get-rules [% prefix])) ["table" "metric" "field"])))
-    (tru "invalid value for prefix")))
+    (deferred-tru "invalid value for prefix")))
 
 (def ^:private Rule
   (su/with-api-error-message
@@ -47,7 +47,7 @@
                                     :rule)
                               (rules/get-rules [toplevel])))
                       ["table" "metric" "field"])))
-    (tru "invalid value for rule name")))
+    (deferred-tru "invalid value for rule name")))
 
 (def ^:private ^{:arglists '([s])} decode-base64-json
   (comp #(json/decode % keyword) codecs/bytes->str codec/base64-decode))
@@ -55,7 +55,7 @@
 (def ^:private Base64EncodedJSON
   (su/with-api-error-message
       (s/pred decode-base64-json)
-    (tru "value couldn''t be parsed as base64 encoded JSON")))
+    (deferred-tru "value couldn''t be parsed as base64 encoded JSON")))
 
 (api/defendpoint GET "/database/:id/candidates"
   "Return a list of candidates for automagic dashboards orderd by interestingness."
@@ -96,12 +96,12 @@
 (def ^:private Entity
   (su/with-api-error-message
       (apply s/enum (keys ->entity))
-    (tru "Invalid entity type")))
+    (deferred-tru "Invalid entity type")))
 
 (def ^:private ComparisonEntity
   (su/with-api-error-message
       (s/enum "segment" "adhoc" "table")
-    (tru "Invalid comparison entity type. Can only be one of \"table\", \"segment\", or \"adhoc\"")))
+    (deferred-tru "Invalid comparison entity type. Can only be one of \"table\", \"segment\", or \"adhoc\"")))
 
 (api/defendpoint GET "/:entity/:entity-id-or-query"
   "Return an automagic dashboard for entity `entity` with id `ìd`."
diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj
index d2699fc0268a4f39eadf37a543396f57fefb4260..a3bee81b13f5441a2828c3d2c38a8544ae594e58 100644
--- a/src/metabase/api/common.clj
+++ b/src/metabase/api/common.clj
@@ -11,7 +11,7 @@
             [metabase.api.common.internal :refer :all]
             [metabase.models.interface :as mi]
             [metabase.util
-             [i18n :as ui18n :refer [trs tru]]
+             [i18n :as ui18n :refer [deferred-trs deferred-tru tru]]
              [schema :as su]]
             [schema.core :as s]
             [toucan.db :as db]))
@@ -144,7 +144,7 @@
 
 ;; #### GENERIC 400 RESPONSE HELPERS
 (def ^:private generic-400
-  [400 (tru "Invalid Request.")])
+  [400 (deferred-tru "Invalid Request.")])
 
 (defn check-400
   "Throw a `400` if `arg` is `false` or `nil`, otherwise return as-is."
@@ -159,7 +159,7 @@
 
 ;; #### GENERIC 404 RESPONSE HELPERS
 (def ^:private generic-404
-  [404 (tru "Not found.")])
+  [404 (deferred-tru "Not found.")])
 
 (defn check-404
   "Throw a `404` if `arg` is `false` or `nil`, otherwise return as-is."
@@ -196,7 +196,7 @@
 ;; #### GENERIC 500 RESPONSE HELPERS
 ;; For when you don't feel like writing something useful
 (def ^:private generic-500
-  [500 (tru "Internal server error.")])
+  [500 (deferred-tru "Internal server error.")])
 
 (defn check-500
   "Throw a `500` if `arg` is `false` or `nil`, otherwise return as-is."
@@ -245,7 +245,7 @@
         [arg->schema body]     (u/optional (every-pred map? #(every? symbol? (keys %))) more)
         validate-param-calls   (validate-params arg->schema)]
     (when-not docstr
-      (log/warn (trs "Warning: endpoint {0}/{1} does not have a docstring." (ns-name *ns*) fn-name)))
+      (log/warn (deferred-trs "Warning: endpoint {0}/{1} does not have a docstring." (ns-name *ns*) fn-name)))
     `(def ~(vary-meta fn-name assoc
                       ;; eval the vals in arg->schema to make sure the actual schemas are resolved so we can document
                       ;; their API error messages
@@ -266,7 +266,7 @@
         [arg->schema body]     (u/optional (every-pred map? #(every? symbol? (keys %))) more)
         validate-param-calls   (validate-params arg->schema)]
     (when-not docstr
-      (log/warn (trs "Warning: endpoint {0}/{1} does not have a docstring." (ns-name *ns*) fn-name)))
+      (log/warn (deferred-trs "Warning: endpoint {0}/{1} does not have a docstring." (ns-name *ns*) fn-name)))
     `(def ~(vary-meta fn-name assoc
                       ;; eval the vals in arg->schema to make sure the actual schemas are resolved so we can document
                       ;; their API error messages
diff --git a/src/metabase/api/common/internal.clj b/src/metabase/api/common/internal.clj
index 946c1a024498ec7545aa2871195eef7fb14aeb68..432fe712fd829683b50c8d4ad07a2309ea105eda 100644
--- a/src/metabase/api/common/internal.clj
+++ b/src/metabase/api/common/internal.clj
@@ -254,7 +254,7 @@
   [response]
   ;; Not sure why this is but the JSON serialization middleware barfs if response is just a plain boolean
   (when (m/boolean? response)
-    (throw (Exception. (str (tru "Attempted to return a boolean as an API response. This is not allowed!")))))
+    (throw (Exception. (tru "Attempted to return a boolean as an API response. This is not allowed!"))))
   (if (and (map? response)
            (contains? response :status)
            (contains? response :body))
diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj
index 778347cf89ddcd34a84578d50ff9c38f71a204f4..7210c55182d1ab0234c62fce8a43990e06e1af53 100644
--- a/src/metabase/api/database.clj
+++ b/src/metabase/api/database.clj
@@ -31,7 +31,7 @@
              [sync-metadata :as sync-metadata]]
             [metabase.util
              [cron :as cron-util]
-             [i18n :refer [tru]]
+             [i18n :refer [deferred-tru]]
              [schema :as su]]
             [schema.core :as s]
             [toucan
@@ -45,7 +45,7 @@
                               su/NonBlankString
                               #(u/ignore-exceptions (driver/the-driver %))
                               "Valid database engine")
-    (tru "value must be a valid database engine.")))
+    (deferred-tru "value must be a valid database engine.")))
 
 
 ;;; ----------------------------------------------- GET /api/database ------------------------------------------------
diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj
index 5a838bd773b60c3e832024056ea6cb09c14bc2a3..34a3f15b3214eecc9182b5d3ce53bdd51e7f6923 100644
--- a/src/metabase/api/dataset.clj
+++ b/src/metabase/api/dataset.clj
@@ -65,7 +65,7 @@
     (export-format->context :json) ;-> :json-download"
   [export-format]
   (or (get-in ex/export-formats [export-format :context])
-      (throw (Exception. (str (tru "Invalid export format: {0}" export-format))))))
+      (throw (Exception. (tru "Invalid export format: {0}" export-format)))))
 
 (defn- datetime-str->date
   "Dates are iso formatted, i.e. 2014-09-18T00:00:00.000-07:00. We can just drop the T and everything after it since
diff --git a/src/metabase/api/geojson.clj b/src/metabase/api/geojson.clj
index b6fbcbf1b35f8fe7ec61e5a8dc75e250076a31b6..94c46a28d1e6e407a953cfe37928b43ce61e68b9 100644
--- a/src/metabase/api/geojson.clj
+++ b/src/metabase/api/geojson.clj
@@ -6,7 +6,7 @@
             [metabase.models.setting :as setting :refer [defsetting]]
             [metabase.util :as u]
             [metabase.util
-             [i18n :as ui18n :refer [tru]]
+             [i18n :as ui18n :refer [deferred-tru tru]]
              [schema :as su]]
             [ring.util.response :as rr]
             [schema.core :as s])
@@ -54,7 +54,7 @@
             (valid-json? resource)
             (catch JsonParseException e
               (rethrow-with-message (tru "Unable to parse resource `{0}` as JSON" relative-path-with-prefix) e)))
-          (throw (FileNotFoundException. (str (tru "Unable to find JSON via relative path `{0}`" relative-path-with-prefix)))))))))
+          (throw (FileNotFoundException. (tru "Unable to find JSON via relative path `{0}`" relative-path-with-prefix))))))))
 
 (defn- valid-json-url?
   "Is URL a valid HTTP URL and does it point to valid JSON?"
@@ -85,7 +85,7 @@
   (memoize (fn [url-or-resource-path]
              (or (valid-json-url? url-or-resource-path)
                  (valid-json-resource? url-or-resource-path)
-                 (throw (Exception. (str (tru "Invalid JSON URL or resource: {0}" url-or-resource-path))))))))
+                 (throw (Exception. (tru "Invalid JSON URL or resource: {0}" url-or-resource-path)))))))
 
 (def ^:private CustomGeoJSON
   {s/Keyword {:name                     s/Str
@@ -112,7 +112,7 @@
     (valid-json-url-or-resource? geo-url-or-uri)))
 
 (defsetting custom-geojson
-  (tru "JSON containing information about custom GeoJSON files for use in map visualizations instead of the default US State or World GeoJSON.")
+  (deferred-tru "JSON containing information about custom GeoJSON files for use in map visualizations instead of the default US State or World GeoJSON.")
   :type    :json
   :default {}
   :getter  (fn [] (merge (setting/get-json :custom-geojson) builtin-geojson))
diff --git a/src/metabase/api/public.clj b/src/metabase/api/public.clj
index b07d6173cda64d884885cbb3c45bbc4c2bfbcdfb..c148e2c076e91ff89cf6caf7a86dbff1e6bddf21 100644
--- a/src/metabase/api/public.clj
+++ b/src/metabase/api/public.clj
@@ -216,7 +216,7 @@
                    (matching-dashboard-param-with-target dashboard-params dashcard-param-mappings target)
                    ;; ...but if we *still* couldn't find a match, throw an Exception, because we don't want people
                    ;; trying to inject new params
-                   (throw (Exception. (str (tru "Invalid param: {0}" slug)))))]]
+                   (throw (Exception. (tru "Invalid param: {0}" slug))))]]
         (merge query-param dashboard-param)))))
 
 (defn- check-card-is-in-dashboard
diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj
index 1d6df7fe50dafbbe2c79a8b63879eeee918626d6..2a2be750a4d1c54b3898ced08fdd5f1c4ea39673 100644
--- a/src/metabase/api/routes.clj
+++ b/src/metabase/api/routes.clj
@@ -38,7 +38,7 @@
             [metabase.middleware
              [auth :as middleware.auth]
              [exceptions :as middleware.exceptions]]
-            [metabase.util.i18n :refer [tru]]))
+            [metabase.util.i18n :refer [deferred-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
@@ -91,4 +91,4 @@
   (context "/transform"            [] (+auth transform/routes))
   (context "/user"                 [] (+auth user/routes))
   (context "/util"                 [] util/routes)
-  (route/not-found (constantly {:status 404, :body (tru "API endpoint does not exist.")})))
+  (route/not-found (constantly {:status 404, :body (deferred-tru "API endpoint does not exist.")})))
diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj
index d736889f3a3ce60d28750e445a96d4ce2062d9e7..4a25d25a8478260013dcae694fce2fd83c017660 100644
--- a/src/metabase/api/session.clj
+++ b/src/metabase/api/session.clj
@@ -19,7 +19,7 @@
              [setting :refer [defsetting]]
              [user :as user :refer [User]]]
             [metabase.util
-             [i18n :as ui18n :refer [trs tru]]
+             [i18n :as ui18n :refer [deferred-tru trs tru]]
              [password :as pass]
              [schema :as su]]
             [schema.core :as s]
@@ -47,8 +47,8 @@
    ;; IP Address doesn't have an actual UI field so just show error by username
    :ip-address (throttle/make-throttler :username, :attempts-threshold 50)})
 
-(def ^:private password-fail-message (tru "Password did not match stored password."))
-(def ^:private password-fail-snippet (tru "did not match stored password"))
+(def ^:private password-fail-message (deferred-tru "Password did not match stored password."))
+(def ^:private password-fail-snippet (deferred-tru "did not match stored password"))
 
 (s/defn ^:private ldap-login :- (s/maybe UUID)
   "If LDAP is enabled and a matching user exists return a new Session for them, or `nil` if they couldn't be
@@ -203,10 +203,10 @@
 ;; add more 3rd-party SSO options
 
 (defsetting google-auth-client-id
-  (tru "Client ID for Google Auth SSO. If this is set, Google Auth is considered to be enabled."))
+  (deferred-tru "Client ID for Google Auth SSO. If this is set, Google Auth is considered to be enabled."))
 
 (defsetting google-auth-auto-create-accounts-domain
-  (tru "When set, allow users to sign up on their own if their Google account email address is from this domain."))
+  (deferred-tru "When set, allow users to sign up on their own if their Google account email address is from this domain."))
 
 (defn- google-auth-token-info [^String token]
   (let [{:keys [status body]} (http/post (str "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" token))]
diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj
index 0ff7f2078af431e31f642b68b610865ba16342a9..b7ecdc3ea630cff325f3981213a9fca004f409af 100644
--- a/src/metabase/api/setup.clj
+++ b/src/metabase/api/setup.clj
@@ -75,7 +75,7 @@
     ;; setup database (if needed)
     (when engine
       (when-not (driver/available? engine)
-        (throw (ex-info (str (tru "Cannot create Database: cannot find driver {0}." engine))
+        (throw (ex-info (tru "Cannot create Database: cannot find driver {0}." engine)
                  {:engine engine})))
       (let [db (db/insert! Database
                  (merge
diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj
index 4ee96e68a0172961c9cdba77ce2b4cd54e6f26ee..e67afc4844868eb1dc73ec6ea5d9fad4260e502e 100644
--- a/src/metabase/api/table.clj
+++ b/src/metabase/api/table.clj
@@ -21,7 +21,7 @@
              [table :as table :refer [Table]]]
             [metabase.sync.field-values :as sync-field-values]
             [metabase.util
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs]]
              [schema :as su]]
             [schema.core :as s]
             [toucan
@@ -80,9 +80,9 @@
         (sync/sync-table! updated-table))
       updated-table)))
 
-(def ^:private auto-bin-str (tru "Auto bin"))
-(def ^:private dont-bin-str (tru "Don''t bin"))
-(def ^:private day-str (tru "Day"))
+(def ^:private auto-bin-str (deferred-tru "Auto bin"))
+(def ^:private dont-bin-str (deferred-tru "Don''t bin"))
+(def ^:private day-str (deferred-tru "Day"))
 
 (def ^:private dimension-options
   (let [default-entry [auto-bin-str ["default"]]]
@@ -93,30 +93,30 @@
                      :mbql ["datetime-field" nil param]
                      :type "type/DateTime"})
                   ;; note the order of these options corresponds to the order they will be shown to the user in the UI
-                  [[(tru "Minute") "minute"]
-                   [(tru "Hour") "hour"]
+                  [[(deferred-tru "Minute") "minute"]
+                   [(deferred-tru "Hour") "hour"]
                    [day-str "day"]
-                   [(tru "Week") "week"]
-                   [(tru "Month") "month"]
-                   [(tru "Quarter") "quarter"]
-                   [(tru "Year") "year"]
-                   [(tru "Minute of Hour") "minute-of-hour"]
-                   [(tru "Hour of Day") "hour-of-day"]
-                   [(tru "Day of Week") "day-of-week"]
-                   [(tru "Day of Month") "day-of-month"]
-                   [(tru "Day of Year") "day-of-year"]
-                   [(tru "Week of Year") "week-of-year"]
-                   [(tru "Month of Year") "month-of-year"]
-                   [(tru "Quarter of Year") "quarter-of-year"]])
+                   [(deferred-tru "Week") "week"]
+                   [(deferred-tru "Month") "month"]
+                   [(deferred-tru "Quarter") "quarter"]
+                   [(deferred-tru "Year") "year"]
+                   [(deferred-tru "Minute of Hour") "minute-of-hour"]
+                   [(deferred-tru "Hour of Day") "hour-of-day"]
+                   [(deferred-tru "Day of Week") "day-of-week"]
+                   [(deferred-tru "Day of Month") "day-of-month"]
+                   [(deferred-tru "Day of Year") "day-of-year"]
+                   [(deferred-tru "Week of Year") "week-of-year"]
+                   [(deferred-tru "Month of Year") "month-of-year"]
+                   [(deferred-tru "Quarter of Year") "quarter-of-year"]])
              (conj
               (mapv (fn [[name params]]
                       {:name name
                        :mbql (apply vector "binning-strategy" nil params)
                        :type "type/Number"})
                     [default-entry
-                     [(tru "10 bins") ["num-bins" 10]]
-                     [(tru "50 bins") ["num-bins" 50]]
-                     [(tru "100 bins") ["num-bins" 100]]])
+                     [(deferred-tru "10 bins") ["num-bins" 10]]
+                     [(deferred-tru "50 bins") ["num-bins" 50]]
+                     [(deferred-tru "100 bins") ["num-bins" 100]]])
               {:name dont-bin-str
                :mbql nil
                :type "type/Number"})
@@ -126,10 +126,10 @@
                        :mbql (apply vector "binning-strategy" nil params)
                        :type "type/Coordinate"})
                     [default-entry
-                     [(tru "Bin every 0.1 degrees") ["bin-width" 0.1]]
-                     [(tru "Bin every 1 degree") ["bin-width" 1.0]]
-                     [(tru "Bin every 10 degrees") ["bin-width" 10.0]]
-                     [(tru "Bin every 20 degrees") ["bin-width" 20.0]]])
+                     [(deferred-tru "Bin every 0.1 degrees") ["bin-width" 0.1]]
+                     [(deferred-tru "Bin every 1 degree") ["bin-width" 1.0]]
+                     [(deferred-tru "Bin every 10 degrees") ["bin-width" 10.0]]
+                     [(deferred-tru "Bin every 20 degrees") ["bin-width" 20.0]]])
               {:name dont-bin-str
                :mbql nil
                :type "type/Coordinate"})))))
diff --git a/src/metabase/api/tiles.clj b/src/metabase/api/tiles.clj
index 4b0d1c8e7b67670d5cce918c9672c90ba4565d0c..593013b836a806bc6f0cf5b262f3ff861348bbd1 100644
--- a/src/metabase/api/tiles.clj
+++ b/src/metabase/api/tiles.clj
@@ -103,7 +103,7 @@
   (let [output-stream (ByteArrayOutputStream.)]
     (try
       (when-not (ImageIO/write tile "png" output-stream) ; returns `true` if successful -- see JavaDoc
-        (throw (Exception. (str (tru "No appropriate image writer found!")))))
+        (throw (Exception. (tru "No appropriate image writer found!"))))
       (.flush output-stream)
       (.toByteArray output-stream)
       (catch Throwable e
@@ -153,7 +153,7 @@
         ;; make sure query completed successfully, or API endpoint should return 400
         _
         (when-not (= status :completed)
-          (throw (ex-info (str (tru "Query failed"))
+          (throw (ex-info (tru "Query failed")
                    ;; `result` might be a `core.async` channel or something we're not expecting
                    (assoc (when (map? result) result) :status-code 400))))
 
diff --git a/src/metabase/async/api_response.clj b/src/metabase/async/api_response.clj
index 16f5730becfe4fd818a9b79123c268f86bf33985..998e2e6a072ce2aa845f6ba69e6145caa9ca0d64 100644
--- a/src/metabase/async/api_response.clj
+++ b/src/metabase/async/api_response.clj
@@ -149,15 +149,15 @@
 
               ;; Otherwise if we've been waiting longer than `absolute-max-keepalive-ms` it's time to call it quits
               exceeded-absolute-max-keepalive?
-              (a/>! output-chan (TimeoutException. (str (trs "No response after waiting {0}. Canceling request."
-                                                             (du/format-milliseconds absolute-max-keepalive-ms)))))
+              (a/>! output-chan (TimeoutException. (trs "No response after waiting {0}. Canceling request."
+                                                        (du/format-milliseconds absolute-max-keepalive-ms))))
 
               ;; if input-chan was unexpectedly closed log a message to that effect and return an appropriate error
               ;; rather than letting people wait forever
               input-chan-closed?
               (do
                 (log/error (trs "Input channel unexpectedly closed."))
-                (a/>! output-chan (InterruptedException. (str (trs "Input channel unexpectedly closed."))))))
+                (a/>! output-chan (InterruptedException. (trs "Input channel unexpectedly closed.")))))
             (finally
               (a/close! output-chan)
               (a/close! input-chan))))))))
diff --git a/src/metabase/automagic_dashboards/core.clj b/src/metabase/automagic_dashboards/core.clj
index 5064bcc7d047229f5a15b0d726fc550e3b042ebe..44b75af192bf7a6ee12d55f7248d7e038b86ee3e 100644
--- a/src/metabase/automagic_dashboards/core.clj
+++ b/src/metabase/automagic_dashboards/core.clj
@@ -40,7 +40,7 @@
             [metabase.sync.analyze.classify :as classify]
             [metabase.util
              [date :as date]
-             [i18n :as ui18n :refer [trs tru]]]
+             [i18n :as ui18n :refer [deferred-tru trs tru]]]
             [ring.util.codec :as codec]
             [schema.core :as s]
             [toucan.db :as db])
@@ -74,15 +74,15 @@
   (comp (some-fn :display_name :name) :source))
 
 (def ^:private op->name
-  {:sum       (tru "sum")
-   :avg       (tru "average")
-   :min       (tru "minumum")
-   :max       (tru "maximum")
-   :count     (tru "number")
-   :distinct  (tru "distinct count")
-   :stddev    (tru "standard deviation")
-   :cum-count (tru "cumulative count")
-   :cum-sum   (tru "cumulative sum")})
+  {:sum       (deferred-tru "sum")
+   :avg       (deferred-tru "average")
+   :min       (deferred-tru "minumum")
+   :max       (deferred-tru "maximum")
+   :count     (deferred-tru "number")
+   :distinct  (deferred-tru "distinct count")
+   :stddev    (deferred-tru "standard deviation")
+   :cum-count (deferred-tru "cumulative count")
+   :cum-sum   (deferred-tru "cumulative sum")})
 
 (def ^:private ^{:arglists '([metric])} saved-metric?
   (every-pred (partial mbql.u/is-clause? :metric)
@@ -991,15 +991,15 @@
                                            ;; (no chunking).
                                            first))]
     (let [show (or show max-cards)]
-      (log/infof (str (trs "Applying heuristic {0} to {1}." (:rule rule) full-name)))
-      (log/infof (str (trs "Dimensions bindings:\n{0}"
-                           (->> context
-                                :dimensions
-                                (m/map-vals #(update % :matches (partial map :name)))
-                                u/pprint-to-str))))
-      (log/infof (str (trs "Using definitions:\nMetrics:\n{0}\nFilters:\n{1}"
-                           (->> context :metrics (m/map-vals :metric) u/pprint-to-str)
-                           (-> context :filters u/pprint-to-str))))
+      (log/infof (trs "Applying heuristic {0} to {1}." (:rule rule) full-name))
+      (log/infof (trs "Dimensions bindings:\n{0}"
+                      (->> context
+                           :dimensions
+                           (m/map-vals #(update % :matches (partial map :name)))
+                           u/pprint-to-str)))
+      (log/infof (trs "Using definitions:\nMetrics:\n{0}\nFilters:\n{1}"
+                      (->> context :metrics (m/map-vals :metric) u/pprint-to-str)
+                      (-> context :filters u/pprint-to-str)))
       (-> dashboard
           (populate/create-dashboard show)
           (assoc :related           (related context rule)
@@ -1114,14 +1114,14 @@
   humanize-filter-value (fn [_ [op & args]]
                           (qp.util/normalize-token op)))
 
-(def ^:private unit-name (comp {:minute-of-hour  (tru "minute")
-                                :hour-of-day     (tru "hour")
-                                :day-of-week     (tru "day of week")
-                                :day-of-month    (tru "day of month")
-                                :day-of-year     (tru "day of year")
-                                :week-of-year    (tru "week")
-                                :month-of-year   (tru "month")
-                                :quarter-of-year (tru "quarter")}
+(def ^:private unit-name (comp {:minute-of-hour  (deferred-tru "minute")
+                                :hour-of-day     (deferred-tru "hour")
+                                :day-of-week     (deferred-tru "day of week")
+                                :day-of-month    (deferred-tru "day of month")
+                                :day-of-year     (deferred-tru "day of year")
+                                :week-of-year    (deferred-tru "week")
+                                :month-of-year   (deferred-tru "month")
+                                :quarter-of-year (deferred-tru "quarter")}
                                qp.util/normalize-token))
 
 (defn- field-name
diff --git a/src/metabase/automagic_dashboards/rules.clj b/src/metabase/automagic_dashboards/rules.clj
index f779fde309edb88e314038f7f4a08addb5c6f4fa..aabc7ae5944a51fbdd455cdb492e18dd166288a5 100644
--- a/src/metabase/automagic_dashboards/rules.clj
+++ b/src/metabase/automagic_dashboards/rules.clj
@@ -6,7 +6,7 @@
             [metabase.util :as u]
             [metabase.util
              [files :as files]
-             [i18n :refer [LocalizedString trs tru]]
+             [i18n :refer [deferred-trs deferred-tru LocalizedString]]
              [schema :as su]
              [yaml :as yaml]]
             [schema
@@ -20,7 +20,7 @@
   100)
 
 (def ^:private Score (s/constrained s/Int #(<= 0 % max-score)
-                                    (trs "0 <= score <= {0}" max-score)))
+                                    (deferred-trs "0 <= score <= {0}" max-score)))
 
 (def ^:private MBQL [s/Any])
 
@@ -81,7 +81,7 @@
 (def ^:private Visualization [(s/one s/Str "visualization") su/Map])
 
 (def ^:private Width  (s/constrained s/Int #(<= 1 % populate/grid-width)
-                                     (trs "1 <= width <= {0}" populate/grid-width)))
+                                     (deferred-trs "1 <= width <= {0}" populate/grid-width)))
 (def ^:private Height (s/constrained s/Int pos?))
 
 (def ^:private CardDimension {Identifier {(s/optional-key :aggregation) s/Str}})
@@ -201,13 +201,13 @@
     (s/optional-key :groups)            Groups
     (s/optional-key :indepth)           [s/Any]
     (s/optional-key :dashboard_filters) [s/Str]}
-   valid-metrics-references?            (trs "Valid metrics references")
-   valid-filters-references?            (trs "Valid filters references")
-   valid-group-references?              (trs "Valid group references")
-   valid-order-by-references?           (trs "Valid order_by references")
-   valid-dashboard-filters-references?  (trs "Valid dashboard filters references")
-   valid-dimension-references?          (trs "Valid dimension references")
-   valid-breakout-dimension-references? (trs "Valid card dimension references")))
+   valid-metrics-references?            (deferred-trs "Valid metrics references")
+   valid-filters-references?            (deferred-trs "Valid filters references")
+   valid-group-references?              (deferred-trs "Valid group references")
+   valid-order-by-references?           (deferred-trs "Valid order_by references")
+   valid-dashboard-filters-references?  (deferred-trs "Valid dashboard filters references")
+   valid-dimension-references?          (deferred-trs "Valid dimension references")
+   valid-breakout-dimension-references? (deferred-trs "Valid card dimension references")))
 
 (defn- with-defaults
   [defaults]
@@ -266,7 +266,7 @@
                           [(if (-> table-type ->entity table-type?)
                              (->entity table-type)
                              (->type table-type))])))
-    LocalizedString #(tru %)}))
+    LocalizedString #(deferred-tru %)}))
 
 (def ^:private rules-dir "automagic_dashboards/")
 
diff --git a/src/metabase/cmd/load_from_h2.clj b/src/metabase/cmd/load_from_h2.clj
index b655681971a201095e572563abb2840d76d2b69c..e15528515ffd67e4b6f13f7e608f7d7ae4613c05 100644
--- a/src/metabase/cmd/load_from_h2.clj
+++ b/src/metabase/cmd/load_from_h2.clj
@@ -237,7 +237,7 @@
   (mdb/setup-db!)
 
   (assert (#{:postgres :mysql} (mdb/db-type))
-    (str (trs "Metabase can only transfer data from H2 to Postgres or MySQL/MariaDB.")))
+    (trs "Metabase can only transfer data from H2 to Postgres or MySQL/MariaDB."))
 
   (jdbc/with-db-transaction [target-db-conn (mdb/jdbc-details)]
     (jdbc/db-set-rollback-only! target-db-conn)
diff --git a/src/metabase/cmd/reset_password.clj b/src/metabase/cmd/reset_password.clj
index 81016a1355639c6f0b5dc477efe5d75bfc840b22..b90095fd7059c0b4ad6b11fef5e74f3e0cc603e3 100644
--- a/src/metabase/cmd/reset_password.clj
+++ b/src/metabase/cmd/reset_password.clj
@@ -1,22 +1,22 @@
 (ns metabase.cmd.reset-password
   (:require [metabase.db :as mdb]
             [metabase.models.user :as user :refer [User]]
-            [metabase.util.i18n :refer [trs]]
+            [metabase.util.i18n :refer [deferred-trs trs]]
             [toucan.db :as db]))
 
 (defn- set-reset-token!
   "Set and return a new `reset_token` for the user with EMAIL-ADDRESS."
   [email-address]
   (let [user-id (or (db/select-one-id User, :email email-address)
-                    (throw (Exception. (str (trs "No user found with email address ''{0}''. " email-address)
-                                            (trs "Please check the spelling and try again.")))))]
+                    (throw (Exception. (str (deferred-trs "No user found with email address ''{0}''. " email-address)
+                                            (deferred-trs "Please check the spelling and try again.")))))]
     (user/set-password-reset-token! user-id)))
 
 (defn reset-password!
   "Reset the password for EMAIL-ADDRESS, and return the reset token in a format that can be understood by the Mac App."
   [email-address]
   (mdb/setup-db!)
-  (println (str (trs "Resetting password for {0}..." email-address)
+  (println (str (deferred-trs "Resetting password for {0}..." email-address)
                 "\n"))
   (try
     (println (trs "OK [[[{0}]]]" (set-reset-token! email-address)))
diff --git a/src/metabase/core.clj b/src/metabase/core.clj
index 0e4b908b363490a0a384feecdec8308f9aa51557..69558580d562efab1fd1924bfa52cd91978a4f74 100644
--- a/src/metabase/core.clj
+++ b/src/metabase/core.clj
@@ -20,7 +20,7 @@
              [setting :as setting]
              [user :refer [User]]]
             [metabase.plugins.classloader :as classloader]
-            [metabase.util.i18n :refer [set-locale trs]]
+            [metabase.util.i18n :refer [deferred-trs set-locale trs]]
             [toucan.db :as db]))
 
 ;;; --------------------------------------------------- Lifecycle ----------------------------------------------------
@@ -36,7 +36,7 @@
                          (when-not (= 80 port) (str ":" port))
                          "/setup/")]
     (log/info (u/format-color 'green
-                  (str (trs "Please use the following URL to setup your Metabase installation:")
+                  (str (deferred-trs "Please use the following URL to setup your Metabase installation:")
                        "\n\n"
                        setup-url
                        "\n\n")))))
diff --git a/src/metabase/db/migrations.clj b/src/metabase/db/migrations.clj
index 28a5ba348977991981bdc63107c6543ed321c4d6..81430b58bdd1393f948af2b9f6d66451d508e6a4 100644
--- a/src/metabase/db/migrations.clj
+++ b/src/metabase/db/migrations.clj
@@ -337,9 +337,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 (str (trs "Migrated Dashboards"))
-                                         Pulse     (str (trs "Migrated Pulses"))
-                                         Card      (str (trs "Migrated Questions"))}
+    (doseq [[model new-collection-name] {Dashboard (trs "Migrated Dashboards")
+                                         Pulse     (trs "Migrated Pulses")
+                                         Card      (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 ab17d24ad25a7e3e32d6c1d357a8c0d45a8539e6..60e6d54a8c2b1b3c1b64d6b1927d350f2e61e2c2 100644
--- a/src/metabase/driver.clj
+++ b/src/metabase/driver.clj
@@ -13,7 +13,7 @@
             [metabase.util :as u]
             [metabase.util
              [date :as du]
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs tru]]
              [schema :as su]]
             [schema.core :as s]
             [toucan.db :as db])
@@ -36,7 +36,7 @@
         (log/error e (trs "Failed to notify {0} Database {1} updated" driver id))))))
 
 (defsetting report-timezone
-  (tru "Connection timezone to use when executing queries. Defaults to system timezone.")
+  (deferred-tru "Connection timezone to use when executing queries. Defaults to system timezone.")
   :setter
   (fn [new-value]
     (setting/set-string! :report-timezone new-value)
@@ -122,7 +122,7 @@
       (try
         (apply classloader/require expected-ns require-options)
         (catch Throwable _
-          (throw (Exception. (str (tru "Could not find {0} driver." driver)))))))))
+          (throw (Exception. (tru "Could not find {0} driver." driver))))))))
 
 (defn- load-driver-namespace-if-needed!
   "Load the expected namespace for a `driver` if it has not already been registed. This only works for core Metabase
@@ -142,7 +142,7 @@
           (require-driver-ns driver :reload)
           ;; if *still* not registered, throw an Exception
           (when-not (registered? driver)
-            (throw (Exception. (str (tru "Driver not registered after loading: {0}" driver))))))))))
+            (throw (Exception. (tru "Driver not registered after loading: {0}" driver)))))))))
 
 (s/defn the-driver
   "Like Clojure core `the-ns`. Converts argument to a keyword, then loads and registers the driver if not already done,
@@ -174,8 +174,8 @@
     (let [old-abstract? (boolean (abstract? driver))
           new-abstract? (boolean new-abstract?)]
       (when (not= old-abstract? new-abstract?)
-        (throw (Exception. (str (tru "Error: attempting to change {0} property `:abstract?` from {1} to {2}."
-                                     driver old-abstract? new-abstract?))))))))
+        (throw (Exception. (tru "Error: attempting to change {0} property `:abstract?` from {1} to {2}."
+                                driver old-abstract? new-abstract?)))))))
 
 (defn add-parent!
   "Add a new parent to `driver`."
@@ -220,7 +220,7 @@
       (when abstract?
         (doseq [parent parents
                 :when  (concrete? parent)]
-          (throw (ex-info (str (trs "Abstract drivers cannot derive from concrete parent drivers."))
+          (throw (ex-info (trs "Abstract drivers cannot derive from concrete parent drivers.")
                    {:driver driver, :parent parent}))))
       ;; validate that the registration isn't stomping on things
       (check-abstractness-hasnt-changed driver abstract?)
@@ -544,7 +544,7 @@
   {:arglists '([driver feature])}
   (fn [driver feature]
     (when-not (driver-features feature)
-      (throw (Exception. (str (tru "Invalid driver feature: {0}" feature)))))
+      (throw (Exception. (tru "Invalid driver feature: {0}" feature))))
     [(dispatch-on-initialized-driver driver) feature])
   :hierarchy #'hierarchy)
 
@@ -581,9 +581,12 @@
   custom-field-name)
 
 
-(defmulti ^String humanize-connection-error-message
-  "Return a humanized (user-facing) version of an connection error message string. Generic error messages are provided
-  in `metabase.driver.common/connection-error-messages`; return one of these whenever possible."
+(defmulti humanize-connection-error-message
+  "Return a humanized (user-facing) version of an connection error message.
+  Generic error messages are provided in `metabase.driver.common/connection-error-messages`; return one of these
+  whenever possible.
+  Error messages can be strings, or localized strings, as returned by `metabase.util.i18n/trs` and
+  `metabase.util.i18n/tru`."
   {:arglists '([this message])}
   dispatch-on-initialized-driver
   :hierarchy #'hierarchy)
diff --git a/src/metabase/driver/common.clj b/src/metabase/driver/common.clj
index ae56f979d21cc755ee0dbc6752f0fc1a006e03b9..011b13a58b576fdd300c9c5991477fa74a23a487 100644
--- a/src/metabase/driver/common.clj
+++ b/src/metabase/driver/common.clj
@@ -10,71 +10,71 @@
              [util :as u]]
             [metabase.driver.util :as driver.u]
             [metabase.query-processor.store :as qp.store]
-            [metabase.util.i18n :refer [trs tru]])
+            [metabase.util.i18n :refer [deferred-tru trs tru]])
   (:import java.text.SimpleDateFormat
            org.joda.time.DateTime
            org.joda.time.format.DateTimeFormatter))
 
 (def connection-error-messages
   "Generic error messages that drivers should return in their implementation of `humanize-connection-error-message`."
-  {:cannot-connect-check-host-and-port (str (tru "Hmm, we couldn''t connect to the database.")
+  {:cannot-connect-check-host-and-port (str (deferred-tru "Hmm, we couldn''t connect to the database.")
                                             " "
-                                            (tru "Make sure your host and port settings are correct"))
-   :ssh-tunnel-auth-fail               (str (tru "We couldn''t connect to the ssh tunnel host.")
+                                            (deferred-tru "Make sure your host and port settings are correct"))
+   :ssh-tunnel-auth-fail               (str (deferred-tru "We couldn''t connect to the ssh tunnel host.")
                                             " "
-                                            (tru "Check the username, password."))
-   :ssh-tunnel-connection-fail         (str (tru "We couldn''t connect to the ssh tunnel host.")
+                                            (deferred-tru "Check the username, password."))
+   :ssh-tunnel-connection-fail         (str (deferred-tru "We couldn''t connect to the ssh tunnel host.")
                                             " "
-                                            (tru "Check the hostname and port."))
-   :database-name-incorrect            (tru "Looks like the database name is incorrect.")
-   :invalid-hostname                   (str (tru "It looks like your host is invalid.")
+                                            (deferred-tru "Check the hostname and port."))
+   :database-name-incorrect            (deferred-tru "Looks like the database name is incorrect.")
+   :invalid-hostname                   (str (deferred-tru "It looks like your host is invalid.")
                                             " "
-                                            (tru "Please double-check it and try again."))
-   :password-incorrect                 (tru "Looks like your password is incorrect.")
-   :password-required                  (tru "Looks like you forgot to enter your password.")
-   :username-incorrect                 (tru "Looks like your username is incorrect.")
-   :username-or-password-incorrect     (tru "Looks like the username or password is incorrect.")})
+                                            (deferred-tru "Please double-check it and try again."))
+   :password-incorrect                 (deferred-tru "Looks like your password is incorrect.")
+   :password-required                  (deferred-tru "Looks like you forgot to enter your password.")
+   :username-incorrect                 (deferred-tru "Looks like your username is incorrect.")
+   :username-or-password-incorrect     (deferred-tru "Looks like the username or password is incorrect.")})
 
 ;; TODO - we should rename these from `default-*-details` to `default-*-connection-property`
 
 (def default-host-details
   "Map of the db host details field, useful for `connection-properties` implementations"
   {:name         "host"
-   :display-name (tru "Host")
+   :display-name (deferred-tru "Host")
    :default      "localhost"})
 
 (def default-port-details
   "Map of the db port details field, useful for `connection-properties` implementations. Implementations should assoc a
   `:default` key."
   {:name         "port"
-   :display-name (tru "Port")
+   :display-name (deferred-tru "Port")
    :type         :integer})
 
 (def default-user-details
   "Map of the db user details field, useful for `connection-properties` implementations"
   {:name         "user"
-   :display-name (tru "Database username")
-   :placeholder  (tru "What username do you use to login to the database?")
+   :display-name (deferred-tru "Database username")
+   :placeholder  (deferred-tru "What username do you use to login to the database?")
    :required     true})
 
 (def default-password-details
   "Map of the db password details field, useful for `connection-properties` implementations"
   {:name         "password"
-   :display-name (tru "Database password")
+   :display-name (deferred-tru "Database password")
    :type         :password
    :placeholder  "*******"})
 
 (def default-dbname-details
   "Map of the db name details field, useful for `connection-properties` implementations"
   {:name         "dbname"
-   :display-name (tru "Database name")
-   :placeholder  (tru "birds_of_the_world")
+   :display-name (deferred-tru "Database name")
+   :placeholder  (deferred-tru "birds_of_the_world")
    :required     true})
 
 (def default-ssl-details
   "Map of the db ssl details field, useful for `connection-properties` implementations"
   {:name         "ssl"
-   :display-name (tru "Use a secure connection (SSL)?")
+   :display-name (deferred-tru "Use a secure connection (SSL)?")
    :type         :boolean
    :default      false})
 
@@ -82,7 +82,7 @@
   "Map of the db `additional-options` details field, useful for `connection-properties` implementations. Should assoc a
   `:placeholder` key"
   {:name         "additional-options"
-   :display-name (tru "Additional JDBC connection string options")})
+   :display-name (deferred-tru "Additional JDBC connection string options")})
 
 (def default-options
   "Default options listed above, keyed by name. These keys can be listed in the plugin manifest to specify connection
diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj
index ba2be86dc000ce0b6ada0d5861fec6b3fb61c352..c42255f96a2b1e20e2f6afcf67fbae97c815251b 100644
--- a/src/metabase/driver/h2.clj
+++ b/src/metabase/driver/h2.clj
@@ -16,7 +16,7 @@
             [metabase.util
              [date :as du]
              [honeysql-extensions :as hx]
-             [i18n :refer [tru]]])
+             [i18n :refer [deferred-tru tru]]])
   (:import java.sql.Time
            java.util.Date))
 
@@ -31,7 +31,7 @@
 (defmethod driver/connection-properties :h2 [_]
   [{:name         "db"
     :display-name (tru "Connection String")
-    :placeholder  (str "file:/" (tru "Users/camsaul/bird_sightings/toucans"))
+    :placeholder  (str "file:/" (deferred-tru "Users/camsaul/bird_sightings/toucans"))
     :required     true}])
 
 (defn- connection-string->file+options
@@ -64,7 +64,7 @@
                   (= user "sa"))        ; "sa" is the default USER
           (throw
            (Exception.
-            (str (tru "Running SQL queries against H2 databases using the default (admin) database user is forbidden.")))))))))
+            (tru "Running SQL queries against H2 databases using the default (admin) database user is forbidden."))))))))
 
 (defmethod driver/process-query-in-context :h2 [_ qp]
   (comp qp check-native-query-not-using-default-user))
diff --git a/src/metabase/driver/sql/query_processor.clj b/src/metabase/driver/sql/query_processor.clj
index 9f9df2324b396d75eb9b88a7a1343e85d87ec26f..7bd905204820354d0d4bb32d0a0d1ef36f8c964b 100644
--- a/src/metabase/driver/sql/query_processor.clj
+++ b/src/metabase/driver/sql/query_processor.clj
@@ -21,7 +21,7 @@
             [metabase.query-processor.middleware.annotate :as annotate]
             [metabase.util
              [honeysql-extensions :as hx]
-             [i18n :refer [tru]]
+             [i18n :refer [deferred-tru tru]]
              [schema :as su]]
             [schema.core :as s])
   (:import metabase.util.honeysql_extensions.Identifier))
@@ -581,7 +581,7 @@
     (catch Throwable e
       (try
         (log/error (u/format-color 'red
-                       (str (tru "Invalid HoneySQL form:")
+                       (str (deferred-tru "Invalid HoneySQL form:")
                             "\n"
                             (u/pprint-to-str honeysql-form))))
         (finally
diff --git a/src/metabase/driver/sql_jdbc/execute.clj b/src/metabase/driver/sql_jdbc/execute.clj
index 017420b320dae7d028c1a13a4e272d10a56409cf..c6343090e84f9c13bad47a403e353ddc1f6eae2c 100644
--- a/src/metabase/driver/sql_jdbc/execute.clj
+++ b/src/metabase/driver/sql_jdbc/execute.clj
@@ -47,7 +47,7 @@
   (let [date-string (.getString rs i)]
     (if-let [parsed-date (du/str->date-time date-string (.getTimeZone cal))]
       parsed-date
-      (throw (Exception. (str (tru "Unable to parse date ''{0}''" date-string)))))))
+      (throw (Exception. (tru "Unable to parse date ''{0}''" date-string))))))
 
 (defmulti read-column
   "Read a single value from a single column in a single row from the JDBC ResultSet of a Metabase query. Normal
diff --git a/src/metabase/driver/util.clj b/src/metabase/driver/util.clj
index 00a63005cb0442fd17d61ffcc0412f3de764774f..9ff91f5794010790b50a39019d69cba65f683683 100644
--- a/src/metabase/driver/util.clj
+++ b/src/metabase/driver/util.clj
@@ -31,7 +31,7 @@
       ;; actually if we are going to `throw-exceptions` we'll rethrow the original but attempt to humanize the message
       ;; first
       (catch Throwable e
-        (throw (Exception. (driver/humanize-connection-error-message driver (.getMessage e)) e))))
+        (throw (Exception. (str (driver/humanize-connection-error-message driver (.getMessage e))) e))))
     (try
       (can-connect-with-details? driver details-map :throw-exceptions)
       (catch Throwable e
diff --git a/src/metabase/email.clj b/src/metabase/email.clj
index 0e29c2831edf086e7881accd603156e83410c007..83d36bbebc0e7b3d95b89bcc6d2b128486fe1341 100644
--- a/src/metabase/email.clj
+++ b/src/metabase/email.clj
@@ -3,7 +3,7 @@
             [metabase.models.setting :as setting :refer [defsetting]]
             [metabase.util :as u]
             [metabase.util
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs tru]]
              [schema :as su]]
             [postal
              [core :as postal]
@@ -14,26 +14,26 @@
 ;;; CONFIG
 
 (defsetting email-from-address
-  (tru "Email address you want to use as the sender of Metabase.")
+  (deferred-tru "Email address you want to use as the sender of Metabase.")
   :default "notifications@metabase.com")
 
 (defsetting email-smtp-host
-  (tru "The address of the SMTP server that handles your emails."))
+  (deferred-tru "The address of the SMTP server that handles your emails."))
 
 (defsetting email-smtp-username
-  (tru "SMTP username."))
+  (deferred-tru "SMTP username."))
 
 (defsetting email-smtp-password
-  (tru "SMTP password.")
+  (deferred-tru "SMTP password.")
   :sensitive? true)
 
 ;; TODO - smtp-port should be switched to type :integer
 (defsetting email-smtp-port
-  (tru "The port your SMTP server uses for outgoing emails."))
+  (deferred-tru "The port your SMTP server uses for outgoing emails."))
 
 (defsetting email-smtp-security
-  (tru "SMTP secure connection protocol. (tls, ssl, starttls, or none)")
-  :default (tru "none")
+  (deferred-tru "SMTP secure connection protocol. (tls, ssl, starttls, or none)")
+  :default (deferred-tru "none")
   :setter  (fn [new-value]
              (when (some? new-value)
                (assert (contains? #{"tls" "ssl" "none" "starttls"} new-value)))
@@ -87,7 +87,7 @@
   {:style/indent 0}
   [{:keys [subject recipients message-type message]} :- EmailMessage]
   (when-not (email-smtp-host)
-    (let [^String msg (str (tru "SMTP host is not set."))]
+    (let [^String msg (tru "SMTP host is not set.")]
       (throw (Exception. msg))))
   ;; Now send the email
   (send-email! (smtp-settings)
diff --git a/src/metabase/email/messages.clj b/src/metabase/email/messages.clj
index 59bf43d95c8a0048bc352c92284764404c88918c..2ebc057b16322c505b76e380267963be4a2d82d4 100644
--- a/src/metabase/email/messages.clj
+++ b/src/metabase/email/messages.clj
@@ -17,7 +17,7 @@
             [metabase.util
              [date :as du]
              [export :as export]
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-trs trs tru]]
              [quotation :as quotation]
              [urls :as url]]
             [stencil
@@ -46,15 +46,15 @@
    :logoHeader true})
 
 (defn- abandonment-context []
-  {:heading      (str (trs "We’d love your feedback."))
-   :callToAction (str (trs "It looks like Metabase wasn’t quite a match for you.")
+  {:heading      (trs "We’d love your feedback.")
+   :callToAction (str (deferred-trs "It looks like Metabase wasn’t quite a match for you.")
                       " "
-                      (trs "Would you mind taking a fast 5 question survey to help the Metabase team understand why and make things better in the future?"))
+                      (deferred-trs "Would you mind taking a fast 5 question survey to help the Metabase team understand why and make things better in the future?"))
    :link         "https://metabase.com/feedback/inactive"})
 
 (defn- follow-up-context []
-  {:heading      (str (trs "We hope you''ve been enjoying Metabase."))
-   :callToAction (str (trs "Would you mind taking a fast 6 question survey to tell us how it’s going?"))
+  {:heading      (trs "We hope you''ve been enjoying Metabase.")
+   :callToAction (trs "Would you mind taking a fast 6 question survey to tell us how it’s going?")
    :link         "https://metabase.com/feedback/active"})
 
 
@@ -126,7 +126,7 @@
                         :passwordResetUrl password-reset-url
                         :logoHeader       true})]
     (email/send-message!
-      :subject      (str (trs "[Metabase] Password Reset Request"))
+      :subject      (trs "[Metabase] Password Reset Request")
       :recipients   [email]
       :message-type :html
       :message      message-body)))
@@ -165,7 +165,7 @@
                             (random-quote-context))
         message-body (stencil/render-file "metabase/email/notification" context)]
     (email/send-message!
-      :subject      (str (trs "[Metabase] Notification"))
+      :subject      (trs "[Metabase] Notification")
       :recipients   [email]
       :message-type :html
       :message      message-body)))
@@ -215,8 +215,8 @@
   (try
     (create-temp-file suffix)
     (catch IOException e
-      (let [ex-msg (str (tru "Unable to create temp file in `{0}` for email attachments "
-                             (System/getProperty "java.io.tmpdir")))]
+      (let [ex-msg (tru "Unable to create temp file in `{0}` for email attachments "
+                        (System/getProperty "java.io.tmpdir"))]
         (throw (IOException. ex-msg e))))))
 
 (defn- create-result-attachment-map [export-type card-name ^File attachment-file]
diff --git a/src/metabase/integrations/ldap.clj b/src/metabase/integrations/ldap.clj
index a7593b6218ccff9ab373ed1bdb673ce2f5970c98..efd9a6f0c04f4a7eb5122636875c6f2311938091 100644
--- a/src/metabase/integrations/ldap.clj
+++ b/src/metabase/integrations/ldap.clj
@@ -9,7 +9,7 @@
              [setting :as setting :refer [defsetting]]
              [user :as user :refer [User]]]
             [metabase.util :as u]
-            [metabase.util.i18n :refer [tru]]
+            [metabase.util.i18n :refer [deferred-tru tru]]
             [toucan.db :as db])
   (:import [com.unboundid.ldap.sdk DN Filter LDAPConnectionPool LDAPException]))
 
@@ -17,19 +17,19 @@
   "{login}")
 
 (defsetting ldap-enabled
-  (tru "Enable LDAP authentication.")
+  (deferred-tru "Enable LDAP authentication.")
   :type    :boolean
   :default false)
 
 (defsetting ldap-host
-  (tru "Server hostname."))
+  (deferred-tru "Server hostname."))
 
 (defsetting ldap-port
-  (tru "Server port, usually 389 or 636 if SSL is used.")
+  (deferred-tru "Server port, usually 389 or 636 if SSL is used.")
   :default "389")
 
 (defsetting ldap-security
-  (tru "Use SSL, TLS or plain text.")
+  (deferred-tru "Use SSL, TLS or plain text.")
   :default "none"
   :setter  (fn [new-value]
              (when-not (nil? new-value)
@@ -37,42 +37,42 @@
              (setting/set-string! :ldap-security new-value)))
 
 (defsetting ldap-bind-dn
-  (tru "The Distinguished Name to bind as (if any), this user will be used to lookup information about other users."))
+  (deferred-tru "The Distinguished Name to bind as (if any), this user will be used to lookup information about other users."))
 
 (defsetting ldap-password
-  (tru "The password to bind with for the lookup user.")
+  (deferred-tru "The password to bind with for the lookup user.")
   :sensitive? true)
 
 (defsetting ldap-user-base
-  (tru "Search base for users. (Will be searched recursively)"))
+  (deferred-tru "Search base for users. (Will be searched recursively)"))
 
 (defsetting ldap-user-filter
-  (tru "User lookup filter, the placeholder '{login}' will be replaced by the user supplied login.")
+  (deferred-tru "User lookup filter, the placeholder '{login}' will be replaced by the user supplied login.")
   :default "(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))")
 
 (defsetting ldap-attribute-email
-  (tru "Attribute to use for the user's email. (usually ''mail'', ''email'' or ''userPrincipalName'')")
+  (deferred-tru "Attribute to use for the user's email. (usually ''mail'', ''email'' or ''userPrincipalName'')")
   :default "mail")
 
 (defsetting ldap-attribute-firstname
-  (tru "Attribute to use for the user''s first name. (usually ''givenName'')")
+  (deferred-tru "Attribute to use for the user''s first name. (usually ''givenName'')")
   :default "givenName")
 
 (defsetting ldap-attribute-lastname
-  (tru "Attribute to use for the user''s last name. (usually ''sn'')")
+  (deferred-tru "Attribute to use for the user''s last name. (usually ''sn'')")
   :default "sn")
 
 (defsetting ldap-group-sync
-  (tru "Enable group membership synchronization with LDAP.")
+  (deferred-tru "Enable group membership synchronization with LDAP.")
   :type    :boolean
   :default false)
 
 (defsetting ldap-group-base
-  (tru "Search base for groups, not required if your LDAP directory provides a ''memberOf'' overlay. (Will be searched recursively)"))
+  (deferred-tru "Search base for groups, not required if your LDAP directory provides a ''memberOf'' overlay. (Will be searched recursively)"))
 
 (defsetting ldap-group-mappings
   ;; Should be in the form: {"cn=Some Group,dc=...": [1, 2, 3]} where keys are LDAP group DNs and values are lists of MB groups IDs
-  (tru "JSON containing LDAP to Metabase group mappings.")
+  (deferred-tru "JSON containing LDAP to Metabase group mappings.")
   :type    :json
   :default {}
   :getter  (fn []
@@ -80,7 +80,7 @@
   :setter  (fn [new-value]
              (doseq [k (keys new-value)]
                (when-not (DN/isValidDN (name k))
-                 (throw (IllegalArgumentException. (str (tru "{0} is not a valid DN." (name k)))))))
+                 (throw (IllegalArgumentException. (tru "{0} is not a valid DN." (name k))))))
              (setting/set-json! :ldap-group-mappings new-value)))
 
 (defn ldap-configured?
diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj
index 4f9351d9f94b613cf0f1a4be72d0d72a627c4f59..30f60264e556c610de0644bec41809c36711655c 100644
--- a/src/metabase/integrations/slack.clj
+++ b/src/metabase/integrations/slack.clj
@@ -9,12 +9,12 @@
              [util :as u]]
             [metabase.models.setting :as setting :refer [defsetting]]
             [metabase.util
-             [i18n :refer [tru]]
+             [i18n :refer [deferred-tru]]
              [schema :as su]]
             [schema.core :as s]))
 
 ;; Define a setting which captures our Slack api token
-(defsetting slack-token (tru "Slack API bearer token obtained from https://api.slack.com/web#authentication"))
+(defsetting slack-token (deferred-tru "Slack API bearer token obtained from https://api.slack.com/web#authentication"))
 
 (def ^:private ^String slack-api-base-url "https://slack.com/api")
 (def ^:private ^String files-channel-name "metabase_files")
diff --git a/src/metabase/metabot.clj b/src/metabase/metabot.clj
index 153f585014d2a4a15397be79c3aa9052a51b4041..a0ec4df0f76f84b314c763eb0c19b89398e62e35 100644
--- a/src/metabase/metabot.clj
+++ b/src/metabase/metabot.clj
@@ -5,10 +5,10 @@
              [instance :as metabot.instance]
              [websocket :as metabot.websocket]]
             [metabase.models.setting :as setting :refer [defsetting]]
-            [metabase.util.i18n :refer [trs]]))
+            [metabase.util.i18n :refer [deferred-trs trs]]))
 
 (defsetting metabot-enabled
-  (trs "Enable MetaBot, which lets you search for and view your saved questions directly via Slack.")
+  (deferred-trs "Enable MetaBot, which lets you search for and view your saved questions directly via Slack.")
   :type    :boolean
   :default false)
 
diff --git a/src/metabase/metabot/command.clj b/src/metabase/metabot/command.clj
index 8843e44ec04e4bf232b20610a5c6638eadd725c4..60d5aec00867be5e9064c57b5d56e88b56a413cd 100644
--- a/src/metabase/metabot/command.clj
+++ b/src/metabase/metabot/command.clj
@@ -17,7 +17,7 @@
              [permissions :refer [Permissions]]
              [permissions-group :as perms-group]]
             [metabase.util
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs tru]]
              [urls :as urls]]
             [toucan.db :as db]))
 
@@ -125,7 +125,7 @@
 
 (defmethod command :list [& _]
   (let [cards (list-cards)]
-    (str (tru "Here''s your {0} most recent cards:" (count cards))
+    (str (deferred-tru "Here''s your {0} most recent cards:" (count cards))
          "\n"
           (format-cards-list cards))))
 
@@ -145,7 +145,7 @@
       (throw
        (Exception.
         (str
-         (tru "Could you be a little more specific, or use the ID? I found these cards with names that matched:")
+         (deferred-tru "Could you be a little more specific, or use the ID? I found these cards with names that matched:")
          "\n"
          (format-cards-list cards)))))
     first-card))
@@ -160,16 +160,16 @@
     (card-with-name card-id-or-name)
 
     :else
-    (throw (Exception. (str (tru "I don''t know what Card `{0}` is. Give me a Card ID or name." card-id-or-name))))))
+    (throw (Exception. (tru "I don''t know what Card `{0}` is. Give me a Card ID or name." card-id-or-name)))))
 
 (defmethod command :show
   ([_]
-   (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`.")))
+   (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]
    (let [{card-id :id} (id-or-name->card card-id-or-name)]
      (when-not card-id
-       (throw (Exception. (str (tru "Card {0} not found." card-id-or-name)))))
+       (throw (Exception. (tru "Card {0} not found." card-id-or-name))))
      (with-metabot-permissions
        (read-check Card card-id))
      (metabot.slack/async
@@ -177,7 +177,7 @@
                           (pulse/create-slack-attachment-data
                            [(pulse/execute-card card-id, :context :metabot)]))]
          (metabot.slack/post-chat-message! nil attachments)))
-     (str (tru "Ok, just a second..."))))
+     (tru "Ok, just a second...")))
 
   ;; If the card name comes without spaces, e.g. (show 'my 'wacky 'card) turn it into a string an recur: (show "my
   ;; wacky card")
@@ -195,7 +195,7 @@
 
 (defmethod command :help [& _]
   (str
-   (tru "Here''s what I can do: ")
+   (deferred-tru "Here''s what I can do: ")
    (str/join ", " (for [cmd (listed-commands)]
                     (str \` (name cmd) \`)))))
 
diff --git a/src/metabase/metabot/slack.clj b/src/metabase/metabot/slack.clj
index e9553abcc8760023d0df52214316a8eca7d33667..790efeee1b42f96bfc4cc9e3f835ffb1b9fb64fc 100644
--- a/src/metabase/metabot/slack.clj
+++ b/src/metabase/metabot/slack.clj
@@ -30,7 +30,7 @@
 (defn format-exception
   "Format a `Throwable` the way we'd like for posting it on Slack."
   [^Throwable e]
-  (str (tru "Uh oh! :cry:\n> {0}" (.getMessage e))))
+  (tru "Uh oh! :cry:\n> {0}" (.getMessage e)))
 
 ;; TODO - this stuff should be implemented with an agent or something. Or with core.async
 (defn do-async
diff --git a/src/metabase/middleware/security.clj b/src/metabase/middleware/security.clj
index b21205ee89bf9b4bc6cf12434d78479b2f7cc33d..41d9098a3ee2cfc659c4a8db5e9e7a7d39c740ad 100644
--- a/src/metabase/middleware/security.clj
+++ b/src/metabase/middleware/security.clj
@@ -7,7 +7,7 @@
             [metabase.models.setting :refer [defsetting]]
             [metabase.util
              [date :as du]
-             [i18n :as ui18n :refer [tru]]]
+             [i18n :as ui18n :refer [deferred-tru]]]
             [ring.util.codec :refer [base64-encode]])
   (:import java.security.MessageDigest))
 
@@ -91,9 +91,9 @@
       (format "%s %s; " (name k) (str/join " " vs))))})
 
 (defsetting ssl-certificate-public-key
-  (str (tru "Base-64 encoded public key for this site's SSL certificate.")
-       (tru "Specify this to enable HTTP Public Key Pinning.")
-       (tru "See {0} for more information." "http://mzl.la/1EnfqBf")))
+  (str (deferred-tru "Base-64 encoded public key for this site's SSL certificate.")
+       (deferred-tru "Specify this to enable HTTP Public Key Pinning.")
+       (deferred-tru "See {0} for more information." "http://mzl.la/1EnfqBf")))
 ;; TODO - it would be nice if we could make this a proper link in the UI; consider enabling markdown parsing
 
 (defn security-headers
diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj
index 082892ebf3101640802a7b9eddf491644386a2c8..0ba2b1e8799388ef827767d34708c5697bd9ea02 100644
--- a/src/metabase/models/card.clj
+++ b/src/metabase/models/card.clj
@@ -114,8 +114,8 @@
     ;; Cards with queries they wouldn't be allowed to run!
     (when *current-user-id*
       (when-not (query-perms/can-run-query? query)
-        (throw (Exception. (str (tru "You do not have permissions to run ad-hoc native queries against Database {0}."
-                                     (:database query)))))))
+        (throw (Exception. (tru "You do not have permissions to run ad-hoc native queries against Database {0}."
+                                (:database query))))))
     ;; make sure this Card doesn't have circular source query references
     (check-for-circular-source-query-references card)))
 
diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj
index eaef7636c8435aa597f40c28ce0c0dac48524764..fd63ee7a76a73916efa32491924c870c09af8027 100644
--- a/src/metabase/models/collection.clj
+++ b/src/metabase/models/collection.clj
@@ -189,7 +189,7 @@
   "The special Root Collection placeholder object with some extra details to facilitate displaying it on the FE."
   []
   (assoc root-collection
-    :name (str (tru "Our analytics"))
+    :name (tru "Our analytics")
     :id   "root"))
 
 (defn- is-root-collection? [x]
@@ -487,10 +487,10 @@
   [collection :- CollectionWithLocationAndIDOrRoot]
   ;; Make sure we're not trying to archive the Root Collection...
   (when (is-root-collection? collection)
-    (throw (Exception. (str (tru "You cannot archive the Root Collection.")))))
+    (throw (Exception. (tru "You cannot archive the Root Collection."))))
   ;; also make sure we're not trying to archive a PERSONAL Collection
   (when (db/exists? Collection :id (u/get-id collection), :personal_owner_id [:not= nil])
-    (throw (Exception. (str (tru "You cannot archive a Personal Collection.")))))
+    (throw (Exception. (tru "You cannot archive a Personal Collection."))))
   (set
    (for [collection-or-id (cons
                            (parent collection)
@@ -521,12 +521,12 @@
   [collection :- CollectionWithLocationAndIDOrRoot, new-parent :- CollectionWithLocationAndIDOrRoot]
   ;; Make sure we're not trying to move the Root Collection...
   (when (is-root-collection? collection)
-    (throw (Exception. (str (tru "You cannot move the Root Collection.")))))
+    (throw (Exception. (tru "You cannot move the Root Collection."))))
   ;; Needless to say, it makes no sense to move a Collection into itself or into one of its descendants. So let's make
   ;; sure we're not doing that...
   (when (contains? (set (location-path->ids (children-location new-parent)))
                    (u/get-id collection))
-    (throw (Exception. (str (tru "You cannot move a Collection into itself or into one of its descendants.")))))
+    (throw (Exception. (tru "You cannot move a Collection into itself or into one of its descendants."))))
   (set
    (cons (perms/collection-readwrite-path new-parent)
          (perms-for-archiving collection))))
@@ -812,7 +812,7 @@
   ;; You can't delete a Personal Collection! Unless we enable it because we are simultaneously deleting the User
   (when-not *allow-deleting-personal-collections*
     (when (:personal_owner_id collection)
-      (throw (Exception. (str (tru "You cannot delete a Personal Collection!"))))))
+      (throw (Exception. (tru "You cannot delete a Personal Collection!")))))
   ;; Delete permissions records for this Collection
   (db/execute! {:delete-from Permissions
                 :where       [:or
@@ -1019,7 +1019,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))]
-    (str (tru "{0} {1}''s Personal Collection" first-name last-name))))
+    (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 f953724f2fac897776df691912a516b4de1702a2..8cf08c4a78d12366cb89f8a944b7d302023d9c46 100644
--- a/src/metabase/models/collection_revision.clj
+++ b/src/metabase/models/collection_revision.clj
@@ -19,7 +19,7 @@
                                    :after  :json
                                    :remark :clob})
           :pre-insert pre-insert
-          :pre-update (fn [& _] (throw (Exception. (str (tru "You cannot update a CollectionRevision!")))))}))
+          :pre-update (fn [& _] (throw (Exception. (tru "You cannot update a CollectionRevision!"))))}))
 
 
 (defn latest-id
diff --git a/src/metabase/models/humanization.clj b/src/metabase/models/humanization.clj
index 0ac4d27f055be730914cb7362f488835190b0015..d744c7fc8b3f4d532060823a742f29212b5f98d7 100644
--- a/src/metabase/models/humanization.clj
+++ b/src/metabase/models/humanization.clj
@@ -13,7 +13,7 @@
             [clojure.tools.logging :as log]
             [metabase.models.setting :as setting :refer [defsetting]]
             [metabase.util
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs tru]]
              [infer-spaces :refer [infer-spaces]]]
             [toucan.db :as db]))
 
@@ -99,8 +99,8 @@
     ;; check to make sure `new-strategy` is a valid strategy, or throw an Exception it is it not.
     (when-not (get-method name->human-readable-name (keyword new-strategy))
       (throw (IllegalArgumentException.
-              (str (tru "Invalid humanization strategy ''{0}''. Valid strategies are: {1}"
-                        new-strategy (keys (methods name->human-readable-name)))))))
+               (tru "Invalid humanization strategy ''{0}''. Valid strategies are: {1}"
+                    new-strategy (keys (methods name->human-readable-name))))))
     (let [old-strategy (setting/get-string :humanization-strategy)]
       ;; ok, now set the new value
       (setting/set-string! :humanization-strategy (some-> new-value name))
@@ -110,10 +110,10 @@
       (re-humanize-table-and-field-names! old-strategy))))
 
 (defsetting ^{:added "0.28.0"} humanization-strategy
-  (str (tru "Metabase can attempt to transform your table and field names into more sensible, human-readable versions, e.g. \"somehorriblename\" becomes \"Some Horrible Name\".")
+  (str (deferred-tru "Metabase can attempt to transform your table and field names into more sensible, human-readable versions, e.g. \"somehorriblename\" becomes \"Some Horrible Name\".")
        " "
-       (tru "This doesn’t work all that well if the names are in a language other than English, however.")
+       (deferred-tru "This doesn’t work all that well if the names are in a language other than English, however.")
        " "
-       (tru "Do you want us to take a guess?"))
+       (deferred-tru "Do you want us to take a guess?"))
   :default "advanced"
   :setter  set-humanization-strategy!)
diff --git a/src/metabase/models/metric.clj b/src/metabase/models/metric.clj
index 9b6664d3668b26bf08cf5b8aaab1befaf256d49a..da515f56ae652bf47c29c857107d2db66da0542c 100644
--- a/src/metabase/models/metric.clj
+++ b/src/metabase/models/metric.clj
@@ -25,7 +25,7 @@
     ;; throw an Exception if someone tries to update creator_id
     (when (contains? updates :creator_id)
       (when (not= creator_id (db/select-one-field :creator_id Metric :id id))
-        (throw (UnsupportedOperationException. (str (tru "You cannot update the creator_id of a Metric."))))))))
+        (throw (UnsupportedOperationException. (tru "You cannot update the creator_id of a Metric.")))))))
 
 (defn- pre-delete [{:keys [id]}]
   (db/delete! 'MetricImportantField :metric_id id))
diff --git a/src/metabase/models/params.clj b/src/metabase/models/params.clj
index 808af16e73824e3eb3238d4f96d9810729d2525d..de818133394aafa338920466bfa7c19a3f973777 100644
--- a/src/metabase/models/params.clj
+++ b/src/metabase/models/params.clj
@@ -7,7 +7,7 @@
              [util :as u]]
             [metabase.mbql.util :as mbql.u]
             [metabase.util
-             [i18n :as ui18n :refer [trs tru]]
+             [i18n :as ui18n :refer [deferred-trs tru]]
              [schema :as su]]
             [schema.core :as s]
             [toucan
@@ -39,7 +39,7 @@
     [:field-id field-id-or-form]
 
     :else
-    (throw (IllegalArgumentException. (str (trs "Don't know how to wrap:") " " field-id-or-form)))))
+    (throw (IllegalArgumentException. (str (deferred-trs "Don't know how to wrap:") " " field-id-or-form)))))
 
 (defn- field-ids->param-field-values
   "Given a collection of `param-field-ids` return a map of FieldValues for the Fields they reference. This map is
diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj
index 6c0bb2d1d05a537345163543d92f741e09d8f860..7b724f642518f68edb21e1097d88644865200d23 100644
--- a/src/metabase/models/permissions.clj
+++ b/src/metabase/models/permissions.clj
@@ -15,7 +15,7 @@
              [permissions-revision :as perms-revision :refer [PermissionsRevision]]]
             [metabase.util
              [honeysql-extensions :as hx]
-             [i18n :as ui18n :refer [trs tru]]
+             [i18n :as ui18n :refer [deferred-tru trs tru]]
              [schema :as su]]
             [schema.core :as s]
             [toucan
@@ -89,7 +89,7 @@
              (not (valid-object-path? object))
              (or (not= object "/")
                  (not *allow-root-entries*)))
-    (throw (ex-info (str (tru "Invalid permissions object path: ''{0}''." object))
+    (throw (ex-info (tru "Invalid permissions object path: ''{0}''." object)
              {:status-code 400, :path object}))))
 
 (defn- assert-valid-metabot-permissions
@@ -98,7 +98,7 @@
   [{:keys [object group_id]}]
   (when (and (= group_id (:id (group/metabot)))
              (not (str/starts-with? object "/collection/")))
-    (throw (ex-info (str (tru "MetaBot can only have Collection permissions."))
+    (throw (ex-info (tru "MetaBot can only have Collection permissions.")
              {:status-code 400}))))
 
 (defn- assert-valid
@@ -245,8 +245,8 @@
     (log/debug (u/format-color 'green "Granting permissions for group %d: %s" (:group_id permissions) (:object permissions)))))
 
 (defn- pre-update [_]
-  (throw (Exception. (str (tru "You cannot update a permissions entry!")
-                          (tru "Delete it and create a new one.")))))
+  (throw (Exception. (str (deferred-tru "You cannot update a permissions entry!")
+                          (deferred-tru "Delete it and create a new one.")))))
 
 (defn- pre-delete [permissions]
   (log/debug (u/format-color 'red "Revoking permissions for group %d: %s" (:group_id permissions) (:object permissions)))
@@ -490,7 +490,7 @@
            (if (map? collection-or-id)
              collection-or-id
              (db/select-one 'Collection :id (u/get-id collection-or-id))))
-      (throw (Exception. (str (tru "You cannot edit permissions for a Personal Collection or its descendants.")))))))
+      (throw (Exception. (tru "You cannot edit permissions for a Personal Collection or its descendants."))))))
 
 (defn revoke-collection-permissions!
   "Revoke all access for `group-or-id` to a Collection."
@@ -574,9 +574,9 @@
    Return a 409 (Conflict) if the numbers don't match up."
   [old-graph new-graph]
   (when (not= (:revision old-graph) (:revision new-graph))
-    (throw (ui18n/ex-info (str (tru "Looks like someone else edited the permissions and your data is out of date.")
+    (throw (ui18n/ex-info (str (deferred-tru "Looks like someone else edited the permissions and your data is out of date.")
                                " "
-                               (tru "Please fetch new data and try again."))
+                               (deferred-tru "Please fetch new data and try again."))
              {:status-code 409}))))
 
 (defn- save-perms-revision!
diff --git a/src/metabase/models/permissions_revision.clj b/src/metabase/models/permissions_revision.clj
index d28f3c207c904681185e050ca7164c48f569824f..1530e9ae8f69329edcc6fb12c7a9662f6ad88ec7 100644
--- a/src/metabase/models/permissions_revision.clj
+++ b/src/metabase/models/permissions_revision.clj
@@ -19,7 +19,7 @@
                                    :after  :json
                                    :remark :clob})
           :pre-insert pre-insert
-          :pre-update (fn [& _] (throw (Exception. (str (tru "You cannot update a PermissionsRevision!")))))}))
+          :pre-update (fn [& _] (throw (Exception. (tru "You cannot update a PermissionsRevision!"))))}))
 
 
 (defn latest-id
diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj
index 0b78b2cf9426f97ab3bf77a584c828c237f6a9da..5e686373a84d79129a08babe436af3cca3cdd554 100644
--- a/src/metabase/models/pulse.clj
+++ b/src/metabase/models/pulse.clj
@@ -28,7 +28,7 @@
              [pulse-channel :as pulse-channel :refer [PulseChannel]]
              [pulse-channel-recipient :refer [PulseChannelRecipient]]]
             [metabase.util
-             [i18n :refer [tru]]
+             [i18n :refer [deferred-tru tru]]
              [schema :as su]]
             [schema.core :as s]
             [toucan
@@ -54,7 +54,7 @@
    ;; can only have one Card
    (-> (hydrate alert :cards) :cards first)
    ;; if there's still not a Card, throw an Exception!
-   (throw (Exception. (str (tru "Invalid Alert: Alert does not have a Card assoicated with it"))))))
+   (throw (Exception. (tru "Invalid Alert: Alert does not have a Card assoicated with it")))))
 
 (defn- perms-objects-set
   "Permissions to read or write a *Pulse* are the same as those of its parent Collection.
@@ -94,7 +94,7 @@
   (su/with-api-error-message {:id          su/IntGreaterThanZero
                               :include_csv s/Bool
                               :include_xls s/Bool}
-    (tru "value must be a map with the keys `{0}`, `{1}`, and `{2}`." "id" "include_csv" "include_xls")))
+    (deferred-tru "value must be a map with the keys `{0}`, `{1}`, and `{2}`." "id" "include_csv" "include_xls")))
 
 (def HybridPulseCard
   "This schema represents the cards that are included in a pulse. This is the data from the `PulseCard` and some
@@ -106,7 +106,7 @@
               :description   (s/maybe s/Str)
               :display       (s/maybe su/KeywordOrString)
               :collection_id (s/maybe su/IntGreaterThanZero)})
-    (tru "value must be a map with the following keys `({0})`"
+    (deferred-tru "value must be a map with the following keys `({0})`"
          (str/join ", " ["collection_id" "description" "display" "id" "include_csv" "include_xls" "name"]))))
 
 (def CoercibleToCardRef
diff --git a/src/metabase/models/query/permissions.clj b/src/metabase/models/query/permissions.clj
index 8f8cb053677f1d956678b30a90f35dfdd3a0f735..2991a9c7099bfe8586f83395b23eeb67376580fb 100644
--- a/src/metabase/models/query/permissions.clj
+++ b/src/metabase/models/query/permissions.clj
@@ -84,7 +84,7 @@
   query."
   [source-card-id :- su/IntGreaterThanZero]
   (i/perms-objects-set (or (db/select-one ['Card :collection_id] :id source-card-id)
-                           (throw (Exception. (str (tru "Card {0} does not exist." source-card-id)))))
+                           (throw (Exception. (tru "Card {0} does not exist." source-card-id))))
                        :read))
 
 (defn- preprocess-query [query]
@@ -127,7 +127,7 @@
     (empty? query)                   #{}
     (= (keyword query-type) :native) #{(perms/adhoc-native-query-path database)}
     (= (keyword query-type) :query)  (mbql-permissions-path-set query perms-opts)
-    :else                            (throw (Exception. (str (tru "Invalid query type: {0}" query-type))))))
+    :else                            (throw (Exception. (tru "Invalid query type: {0}" query-type)))))
 
 (defn perms-set
   "Calculate the set of permissions required to run an ad-hoc `query`. Returns permissions for full table access.
diff --git a/src/metabase/models/query_execution.clj b/src/metabase/models/query_execution.clj
index 23624a98a545fceaf1f4f159458580ab7a821a07..2158c15f65b164057f0d23ba472dd2e8e7de5e34 100644
--- a/src/metabase/models/query_execution.clj
+++ b/src/metabase/models/query_execution.clj
@@ -25,5 +25,5 @@
   (merge models/IModelDefaults
          {:types       (constantly {:json_query :json, :status :keyword, :context :keyword, :error :clob})
           :pre-insert  pre-insert
-          :pre-update  (fn [& _] (throw (Exception. (str (tru "You cannot update a QueryExecution!")))))
+          :pre-update  (fn [& _] (throw (Exception. (tru "You cannot update a QueryExecution!"))))
           :post-select post-select}))
diff --git a/src/metabase/models/revision.clj b/src/metabase/models/revision.clj
index 1bd7473602d45173a3f9978c59289a2cc5139535..7009e0917e251bb56d74c48f71b026322f1d2490 100644
--- a/src/metabase/models/revision.clj
+++ b/src/metabase/models/revision.clj
@@ -84,7 +84,7 @@
   (merge models/IModelDefaults
          {:types       (constantly {:object :json, :message :clob})
           :pre-insert  pre-insert
-          :pre-update  (fn [& _] (throw (Exception. (str (tru "You cannot update a Revision!")))))
+          :pre-update  (fn [& _] (throw (Exception. (tru "You cannot update a Revision!"))))
           :post-select do-post-select-for-object}))
 
 
diff --git a/src/metabase/models/segment.clj b/src/metabase/models/segment.clj
index ec81b2f69f30e01c945373d228324ad978dec067..ccd48a8ec29958123f949272755018987d889dc9 100644
--- a/src/metabase/models/segment.clj
+++ b/src/metabase/models/segment.clj
@@ -22,7 +22,7 @@
     ;; throw an Exception if someone tries to update creator_id
     (when (contains? updates :creator_id)
       (when (not= creator_id (db/select-one-field :creator_id Segment :id id))
-        (throw (UnsupportedOperationException. (str (tru "You cannot update the creator_id of a Segment."))))))))
+        (throw (UnsupportedOperationException. (tru "You cannot update the creator_id of a Segment.")))))))
 
 (defn- perms-objects-set [segment read-or-write]
   (let [table (or (:table segment)
diff --git a/src/metabase/models/setting.clj b/src/metabase/models/setting.clj
index 1acd257c140f83529f2c288981a5e1dc9e1e768d..6cae36c2d9ed9c387eefcd424441941e453ed553 100644
--- a/src/metabase/models/setting.clj
+++ b/src/metabase/models/setting.clj
@@ -43,7 +43,7 @@
             [metabase.models.setting.cache :as cache]
             [metabase.util
              [date :as du]
-             [i18n :as ui18n :refer [trs tru]]]
+             [i18n :as ui18n :refer [deferred-trs deferred-tru trs tru]]]
             [schema.core :as s]
             [toucan
              [db :as db]
@@ -95,7 +95,7 @@
     (let [k (keyword setting-or-name)]
       (or (@registered-settings k)
           (throw (Exception.
-                  (str (tru "Setting {0} does not exist.\nFound: {1}" k (sort (keys @registered-settings))))))))))
+                  (tru "Setting {0} does not exist.\nFound: {1}" k (sort (keys @registered-settings)))))))))
 
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
@@ -156,7 +156,7 @@
       "true"  true
       "false" false
       (throw (Exception.
-              (str (tru "Invalid value for string: must be either \"true\" or \"false\" (case-insensitive).")))))))
+              (tru "Invalid value for string: must be either \"true\" or \"false\" (case-insensitive)."))))))
 
 (defn get-boolean
   "Get boolean value of (presumably `:boolean`) `setting-or-name`. This is the default getter for `:boolean` settings.
@@ -238,9 +238,9 @@
        ;; and there's actually a row in the DB that's not in the cache for some reason. Go ahead and update the
        ;; existing value and log a warning
        (catch Throwable e
-         (log/warn (tru "Error inserting a new Setting:") "\n"
+         (log/warn (deferred-tru "Error inserting a new Setting:") "\n"
                    (.getMessage e) "\n"
-                   (tru "Assuming Setting already exists in DB and updating existing value."))
+                   (deferred-tru "Assuming Setting already exists in DB and updating existing value."))
          (update-setting! setting-name new-value))))
 
 (defn- obfuscated-value? [v]
@@ -444,7 +444,7 @@
     ((set symbols) (first expression))))
 
 (defn- valid-trs-or-tru? [desc]
-  (is-expression? #{'trs 'tru `trs `tru} desc))
+  (is-expression? #{'deferred-trs 'deferred-tru `deferred-trs `deferred-tru} desc))
 
 (defn- valid-str-of-trs-or-tru? [maybe-str-expr]
   (when (is-expression? #{'str `str} maybe-str-expr)
@@ -462,15 +462,15 @@
   (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))))))
+             (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:
 
-     (defsetting mandrill-api-key \"API key for Mandrill.\")
+     (defsetting mandrill-api-key (trs \"API key for Mandrill.\"))
      (mandrill-api-key)           ; get the value
      (mandrill-api-key new-value) ; update the value
      (mandrill-api-key nil)       ; delete the value
@@ -585,7 +585,7 @@
      :env_name       (env-var-name setting)
      :description    (str description)
      :default        (if set-via-env-var?
-                       (str (tru "Using value of env var {0}" (str \$ (env-var-name setting))))
+                       (tru "Using value of env var {0}" (str \$ (env-var-name setting)))
                        default)}))
 
 (defn all
diff --git a/src/metabase/models/setting/cache.clj b/src/metabase/models/setting/cache.clj
index 35334a2ff91b2ecc73eb767b495b499f6451cd41..a3578389a59415e70d1ecf1566f24f780d7d426e 100644
--- a/src/metabase/models/setting/cache.clj
+++ b/src/metabase/models/setting/cache.clj
@@ -100,7 +100,7 @@
                           [:> :value last-known-update]]})
         (when <>
           (log/info (u/format-color 'red
-                        (str (trs "Settings have been changed on another instance, and will be reloaded here."))))))))))
+                        (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)?"
diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj
index 0eda08ed54a8a05007d353e0f5ddc6b937060c97..9d2bdaa14d145470b786282f1611c39a3578e8e1 100644
--- a/src/metabase/models/user.clj
+++ b/src/metabase/models/user.clj
@@ -15,7 +15,7 @@
              [permissions-group-membership :as perm-membership :refer [PermissionsGroupMembership]]]
             [metabase.util
              [date :as du]
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs]]
              [schema :as su]]
             [schema.core :as s]
             [toucan
@@ -178,7 +178,7 @@
 (def LoginAttributes
   "Login attributes, currently not collected for LDAP or Google Auth. Will ultimately be stored as JSON"
   (su/with-api-error-message {su/KeywordOrString (s/cond-pre s/Str s/Num)}
-    (tru "value must be a map with each value either a string or number.")))
+    (deferred-tru "value must be a map with each value either a string or number.")))
 
 (def NewUser
   "Required/optionals parameters needed to create a new user (for any backend)"
diff --git a/src/metabase/plugins.clj b/src/metabase/plugins.clj
index cfe4642677fde37ba6e680dcbdc46fadf7a8c660..92780b8a5c68d02e03ea4b68abe7804dc440ff10 100644
--- a/src/metabase/plugins.clj
+++ b/src/metabase/plugins.clj
@@ -26,7 +26,7 @@
        (u/prog1 (files/get-path filename)
          (files/create-dir-if-not-exists! <>)
          (assert (Files/isWritable <>)
-           (str (trs "Metabase does not have permissions to write to plugins directory {0}" filename))))
+           (trs "Metabase does not have permissions to write to plugins directory {0}" filename)))
        ;; If we couldn't create the directory, or the directory is not writable, fall back to a temporary directory
        ;; rather than failing to launch entirely. Log instructions for what should be done to fix the problem.
        (catch Throwable e
@@ -41,7 +41,7 @@
          ;; gracefully proceed here. Throw an Exception detailing the critical issues.
          (u/prog1 (files/get-path (System/getProperty "java.io.tmpdir"))
            (assert (Files/isWritable <>)
-             (str (trs "Metabase cannot write to temporary directory. Please set MB_PLUGINS_DIR to a writable directory and restart Metabase.")))))))))
+             (trs "Metabase cannot write to temporary directory. Please set MB_PLUGINS_DIR to a writable directory and restart Metabase."))))))))
 
 ;; Actual logic is wrapped in a delay rather than a normal function so we don't log the error messages more than once
 ;; in cases where we have to fall back to the system temporary directory
diff --git a/src/metabase/plugins/classloader.clj b/src/metabase/plugins/classloader.clj
index f59e35260618e9b292efb01615174f4344c811d9..c0c38ac2dbf8a6196e36f1ec9341eb7422bf53f1 100644
--- a/src/metabase/plugins/classloader.clj
+++ b/src/metabase/plugins/classloader.clj
@@ -14,7 +14,7 @@
   (:refer-clojure :exclude [require])
   (:require [clojure.tools.logging :as log]
             [dynapath.util :as dynapath]
-            [metabase.util.i18n :refer [trs]])
+            [metabase.util.i18n :refer [deferred-trs]])
   (:import [clojure.lang DynamicClassLoader RT]
            java.net.URL))
 
@@ -32,7 +32,7 @@
    (or
     (when-let [base-loader (RT/baseLoader)]
       (when (instance? DynamicClassLoader base-loader)
-        (log/debug (trs "Using Clojure base loader as shared context classloader: {0}" base-loader))
+        (log/debug (deferred-trs "Using Clojure base loader as shared context classloader: {0}" base-loader))
         base-loader))
     ;; Otherwise if we need to create our own go ahead and do it
     ;;
@@ -42,7 +42,7 @@
     ;; context classloaders by giving them this one. No other places in the codebase should be modifying classloaders
     ;; anyway.
     (let [new-classloader (DynamicClassLoader. (.getContextClassLoader (Thread/currentThread)))]
-      (log/debug (trs "Using NEWLY CREATED classloader as shared context classloader: {0}" new-classloader))
+      (log/debug (deferred-trs "Using NEWLY CREATED classloader as shared context classloader: {0}" new-classloader))
       new-classloader))))
 
 
@@ -81,7 +81,8 @@
        current-thread-context-classloader))
    ;; otherwise set the current thread's context classloader to the shared context classloader
    (let [shared-classloader @shared-context-classloader]
-     (log/debug (trs "Setting current thread context classloader to shared classloader {0}..." shared-classloader))
+     (log/debug
+       (deferred-trs "Setting current thread context classloader to shared classloader {0}..." shared-classloader))
      (.setContextClassLoader (Thread/currentThread) shared-classloader)
      shared-classloader)))
 
@@ -134,4 +135,4 @@
     ;; `add-classpath-url` will return non-truthy if it couldn't add the URL, e.g. because the classloader wasn't one
     ;; that allowed it
     (assert (dynapath/add-classpath-url (the-top-level-classloader) url))
-    (log/info (trs "Added URL {0} to classpath" url))))
+    (log/info (deferred-trs "Added URL {0} to classpath" url))))
diff --git a/src/metabase/plugins/lazy_loaded_driver.clj b/src/metabase/plugins/lazy_loaded_driver.clj
index b8f36dff5a5e21e2694b1fad32a30a1ce7090a75..7f65f714bdc46e1f6e5635aad4964c108c794aba 100644
--- a/src/metabase/plugins/lazy_loaded_driver.clj
+++ b/src/metabase/plugins/lazy_loaded_driver.clj
@@ -22,10 +22,10 @@
   (cond
     (string? prop)
     (or (driver.common/default-options (keyword prop))
-        (throw (Exception. (str (trs "Default connection property {0} does not exist." prop)))))
+        (throw (Exception. (trs "Default connection property {0} does not exist." prop))))
 
     (not (map? prop))
-    (throw (Exception. (str (trs "Invalid connection property {0}: not a string or map." prop))))
+    (throw (Exception. (trs "Invalid connection property {0}: not a string or map." prop)))
 
     (:merge prop)
     (reduce merge (map parse-connection-property (:merge prop)))
@@ -72,7 +72,7 @@
         connection-props (parse-connection-properties driver-info)]
     ;; Make sure the driver has required properties like driver-name
     (when-not (seq driver-name)
-      (throw (ex-info (str (trs "Cannot initialize plugin: missing required property `driver-name`"))
+      (throw (ex-info (trs "Cannot initialize plugin: missing required property `driver-name`")
                driver-info)))
     ;; if someone forgot to include connection properties for a non-abstract driver throw them a bone and warn them
     ;; about it
diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj
index 0f94e7dcc6bcbf2b45b47d9a7d24767f7f63c895..6b4aa3ddc9758fbda659481a20b45c9b03791ee9 100644
--- a/src/metabase/public_settings.clj
+++ b/src/metabase/public_settings.clj
@@ -12,23 +12,23 @@
             [metabase.plugins.classloader :as classloader]
             [metabase.public-settings.metastore :as metastore]
             [metabase.util
-             [i18n :refer [available-locales-with-names set-locale trs tru]]
+             [i18n :refer [available-locales-with-names deferred-tru set-locale trs tru]]
              [password :as password]]
             [toucan.db :as db])
   (:import [java.util TimeZone UUID]))
 
 (defsetting check-for-updates
-  (tru "Identify when new versions of Metabase are available.")
+  (deferred-tru "Identify when new versions of Metabase are available.")
   :type    :boolean
   :default true)
 
 (defsetting version-info
-  (tru "Information about available versions of Metabase.")
+  (deferred-tru "Information about available versions of Metabase.")
   :type    :json
   :default {})
 
 (defsetting site-name
-  (tru "The name used for this instance of Metabase.")
+  (deferred-tru "The name used for this instance of Metabase.")
   :default "Metabase")
 
 (defsetting site-uuid
@@ -54,13 +54,13 @@
             (str "http://" s))]
     ;; check that the URL is valid
     (assert (u/url? s)
-      (str (tru "Invalid site URL: {0}" s)))
+      (tru "Invalid site URL: {0}" s))
     s))
 
 ;; This value is *guaranteed* to never have a trailing slash :D
 ;; It will also prepend `http://` to the URL if there's not protocol when it comes in
 (defsetting site-url
-  (tru "The base URL of this Metabase instance, e.g. \"http://metabase.my-company.com\".")
+  (deferred-tru "The base URL of this Metabase instance, e.g. \"http://metabase.my-company.com\".")
   :getter (fn []
             (try
               (some-> (setting/get-string :site-url) normalize-site-url)
@@ -70,9 +70,9 @@
             (setting/set-string! :site-url (some-> new-value normalize-site-url))))
 
 (defsetting site-locale
-  (str  (tru "The default language for this Metabase instance.")
+  (str  (deferred-tru "The default language for this Metabase instance.")
         " "
-        (tru "This only applies to emails, Pulses, etc. Users'' browsers will specify the language used in the user interface."))
+        (deferred-tru "This only applies to emails, Pulses, etc. Users'' browsers will specify the language used in the user interface."))
   :type    :string
   :setter  (fn [new-value]
              (setting/set-string! :site-locale new-value)
@@ -80,35 +80,35 @@
   :default "en")
 
 (defsetting admin-email
-  (tru "The email address users should be referred to if they encounter a problem."))
+  (deferred-tru "The email address users should be referred to if they encounter a problem."))
 
 (defsetting anon-tracking-enabled
-  (tru "Enable the collection of anonymous usage data in order to help Metabase improve.")
+  (deferred-tru "Enable the collection of anonymous usage data in order to help Metabase improve.")
   :type   :boolean
   :default true)
 
 (defsetting map-tile-server-url
-  (tru "The map tile server URL template used in map visualizations, for example from OpenStreetMaps or MapBox.")
+  (deferred-tru "The map tile server URL template used in map visualizations, for example from OpenStreetMaps or MapBox.")
   :default "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png")
 
 (defsetting enable-public-sharing
-  (tru "Enable admins to create publicly viewable links (and embeddable iframes) for Questions and Dashboards?")
+  (deferred-tru "Enable admins to create publicly viewable links (and embeddable iframes) for Questions and Dashboards?")
   :type    :boolean
   :default false)
 
 (defsetting enable-embedding
-  (tru "Allow admins to securely embed questions and dashboards within other applications?")
+  (deferred-tru "Allow admins to securely embed questions and dashboards within other applications?")
   :type    :boolean
   :default false)
 
 (defsetting enable-nested-queries
-  (tru "Allow using a saved question as the source for other queries?")
+  (deferred-tru "Allow using a saved question as the source for other queries?")
   :type    :boolean
   :default true)
 
 
 (defsetting enable-query-caching
-  (tru "Enabling caching will save the results of queries that take a long time to run.")
+  (deferred-tru "Enabling caching will save the results of queries that take a long time to run.")
   :type    :boolean
   :default false)
 
@@ -120,7 +120,7 @@
   (* 200 1024))
 
 (defsetting query-caching-max-kb
-  (tru "The maximum size of the cache, per saved question, in kilobytes:")
+  (deferred-tru "The maximum size of the cache, per saved question, in kilobytes:")
   ;; (This size is a measurement of the length of *uncompressed* serialized result *rows*. The actual size of
   ;; the results as stored will vary somewhat, since this measurement doesn't include metadata returned with the
   ;; results, and doesn't consider whether the results are compressed, as the `:db` backend does.)
@@ -133,43 +133,43 @@
                            global-max-caching-kb))
                (throw (IllegalArgumentException.
                        (str
-                        (tru "Failed setting `query-caching-max-kb` to {0}." new-value)
-                        (tru "Values greater than {1} are not allowed." global-max-caching-kb)))))
+                        (deferred-tru "Failed setting `query-caching-max-kb` to {0}." new-value)
+                        (deferred-tru "Values greater than {1} are not allowed." global-max-caching-kb)))))
              (setting/set-integer! :query-caching-max-kb new-value)))
 
 (defsetting query-caching-max-ttl
-  (tru "The absolute maximum time to keep any cached query results, in seconds.")
+  (deferred-tru "The absolute maximum time to keep any cached query results, in seconds.")
   :type    :integer
   :default (* 60 60 24 100)) ; 100 days
 
 (defsetting query-caching-min-ttl
-  (tru "Metabase will cache all saved questions with an average query execution time longer than this many seconds:")
+  (deferred-tru "Metabase will cache all saved questions with an average query execution time longer than this many seconds:")
   :type    :integer
   :default 60)
 
 (defsetting query-caching-ttl-ratio
-  (str (tru "To determine how long each saved question''s cached result should stick around, we take the query''s average execution time and multiply that by whatever you input here.")
-       (tru "So if a query takes on average 2 minutes to run, and you input 10 for your multiplier, its cache entry will persist for 20 minutes."))
+  (str (deferred-tru "To determine how long each saved question''s cached result should stick around, we take the query''s average execution time and multiply that by whatever you input here.")
+       (deferred-tru "So if a query takes on average 2 minutes to run, and you input 10 for your multiplier, its cache entry will persist for 20 minutes."))
   :type    :integer
   :default 10)
 
 (defsetting breakout-bins-num
-  (tru "When using the default binning strategy and a number of bins is not provided, this number will be used as the default.")
+  (deferred-tru "When using the default binning strategy and a number of bins is not provided, this number will be used as the default.")
   :type :integer
   :default 8)
 
 (defsetting breakout-bin-width
-  (tru "When using the default binning strategy for a field of type Coordinate (such as Latitude and Longitude), this number will be used as the default bin width (in degrees).")
+  (deferred-tru "When using the default binning strategy for a field of type Coordinate (such as Latitude and Longitude), this number will be used as the default bin width (in degrees).")
   :type :double
   :default 10.0)
 
 (defsetting custom-formatting
-  (tru "Object keyed by type, containing formatting settings")
+  (deferred-tru "Object keyed by type, containing formatting settings")
   :type    :json
   :default {})
 
 (defsetting enable-xrays
-  (tru "Allow users to explore data using X-rays")
+  (deferred-tru "Allow users to explore data using X-rays")
   :type    :boolean
   :default true)
 
diff --git a/src/metabase/public_settings/metastore.clj b/src/metabase/public_settings/metastore.clj
index 244722fca09792f290a66a98ec564738adbd9fc0..c707ad1e2378f58c24007ba96eae82b4368d25e4 100644
--- a/src/metabase/public_settings/metastore.clj
+++ b/src/metabase/public_settings/metastore.clj
@@ -10,7 +10,7 @@
              [util :as u]]
             [metabase.models.setting :as setting :refer [defsetting]]
             [metabase.util
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs tru]]
              [schema :as su]]
             [schema.core :as s]))
 
@@ -65,17 +65,17 @@
           ;; 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 (str (tru "Unable to validate token: 404 not found."))})
+            {:valid false, :status (tru "Unable to validate token: 404 not found.")})
           ;; 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 (str (tru "There was an error checking whether this token was valid:")
+            {:valid false, :status (str (deferred-tru "There was an error checking whether this token was valid:")
                                         " "
                                         (.getMessage e))})))
    fetch-token-status-timeout-ms
-   {:valid false, :status (str (tru "Token validation timed out."))}))
+   {:valid false, :status (tru "Token validation timed out.")}))
 
 (def ^:private ^{:arglists '([token])} fetch-token-status
   "TTL-memoized version of `fetch-token-status*`. Caches API responses for 5 minutes. This is important to avoid making
@@ -111,14 +111,14 @@
 ;;; +----------------------------------------------------------------------------------------------------------------+
 
 (defsetting premium-embedding-token     ; TODO - rename this to premium-features-token?
-  (tru "Token for premium features. Go to the MetaStore to get yours!")
+  (deferred-tru "Token for premium features. Go to the MetaStore to get yours!")
   :setter
   (fn [new-value]
     ;; validate the new value if we're not unsetting it
     (try
       (when (seq new-value)
         (when (s/check ValidToken new-value)
-          (throw (ex-info (str (tru "Token format is invalid. Token should be 64 hexadecimal characters."))
+          (throw (ex-info (tru "Token format is invalid. Token should be 64 hexadecimal characters.")
                    {:status-code 400})))
         (valid-token->features new-value)
         (log/info (trs "Token is valid.")))
diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj
index a24f3507982d50a60fb5c15d6104123a85f39059..bb5e1492b485941b0c675a766208096ce90a8d7e 100644
--- a/src/metabase/pulse.clj
+++ b/src/metabase/pulse.clj
@@ -13,7 +13,7 @@
              [pulse :refer [Pulse]]]
             [metabase.pulse.render :as render]
             [metabase.util
-             [i18n :refer [trs tru]]
+             [i18n :refer [deferred-tru trs tru]]
              [ui-logic :as ui]
              [urls :as urls]]
             [schema.core :as s]
@@ -107,11 +107,11 @@
                                                             (get-in first-result [:result :data]))]
 
     (when-not (and goal-val comparison-col-rowfn)
-      (throw (Exception. (str (tru "Unable to compare results to goal for alert.")
+      (throw (Exception. (str (deferred-tru "Unable to compare results to goal for alert.")
                               " "
-                              (tru "Question ID is ''{0}'' with visualization settings ''{1}''"
-                                   (get-in results [:card :id])
-                                   (pr-str (get-in results [:card :visualization_settings])))))))
+                              (deferred-tru "Question ID is ''{0}'' with visualization settings ''{1}''"
+                                        (get-in results [:card :id])
+                                        (pr-str (get-in results [:card :visualization_settings])))))))
     (some (fn [row]
             (goal-comparison goal-val (comparison-col-rowfn row)))
           (get-in first-result [:result :data :rows]))))
@@ -140,7 +140,7 @@
     (goal-met? alert results)
 
     :else
-    (let [^String error-text (str (tru "Unrecognized alert with condition ''{0}''" alert_condition))]
+    (let [^String error-text (tru "Unrecognized alert with condition ''{0}''" alert_condition)]
       (throw (IllegalArgumentException. error-text)))))
 
 (defmethod should-send-notification? :pulse
@@ -197,7 +197,7 @@
 
 (defmethod notification :default
   [_ _ {:keys [channel_type] :as channel}]
-  (let [^String ex-msg (str (tru "Unrecognized channel type {0}" (pr-str channel_type)))]
+  (let [^String ex-msg (tru "Unrecognized channel type {0}" (pr-str channel_type))]
     (throw (UnsupportedOperationException. ex-msg))))
 
 (defn- pulse->notifications [{:keys [cards channels channel-ids], pulse-id :id, :as pulse}]
diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj
index 4e270b27d47c8387471a759e5920f3dd2ddb097a..fe8fa0f42493b81fcfde6bb8c92e1cff44ead4cf 100644
--- a/src/metabase/pulse/render.clj
+++ b/src/metabase/pulse/render.clj
@@ -94,7 +94,7 @@
   [render-type timezone card {:keys [data error], :as results}]
   (try
     (when error
-      (let [^String msg (str (tru "Card has errors: {0}" error))]
+      (let [^String msg (tru "Card has errors: {0}" error)]
         (throw (ex-info msg results))))
     (let [chart-type (or (detect-pulse-card-type card data)
                           (when (is-attached? card)
diff --git a/src/metabase/pulse/render/body.clj b/src/metabase/pulse/render/body.clj
index e76532f570a346c693f604e2f40e4cc022b9a325..8c4210748a6805cbe7072c971e63a59dc9d3b592 100644
--- a/src/metabase/pulse/render/body.clj
+++ b/src/metabase/pulse/render/body.clj
@@ -148,7 +148,7 @@
                  (< rows-limit (count rows))))
     [:div {:style (style/style {:color         style/color-gray-2
                                 :margin-bottom :16px})}
-     (str (trs "More results have been included as a file attachment"))]))
+     (trs "More results have been included as a file attachment")]))
 
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
@@ -257,7 +257,7 @@
                      (style/font-style)
                      {:margin-top :8px
                       :color      style/color-gray-4})}
-       (str (trs "No results"))]]}))
+       (trs "No results")]]}))
 
 
 (s/defmethod render :attached :- common/RenderedPulseCard
@@ -274,7 +274,7 @@
                      (style/font-style)
                      {:margin-top :8px
                       :color      style/color-gray-4})}
-       (str (trs "This question has been included as a file attachment"))]]}))
+       (trs "This question has been included as a file attachment")]]}))
 
 
 (s/defmethod render :unknown :- common/RenderedPulseCard
@@ -287,9 +287,9 @@
                   (style/font-style)
                   {:color       style/color-gold
                    :font-weight 700})}
-    (str (trs "We were unable to display this Pulse."))
+    (trs "We were unable to display this Pulse.")
     [:br]
-    (str (trs "Please view this card in Metabase."))]})
+    (trs "Please view this card in Metabase.")]})
 
 
 (s/defmethod render :error :- common/RenderedPulseCard
@@ -303,4 +303,4 @@
                   {:color       style/color-error
                    :font-weight 700
                    :padding     :16px})}
-    (str (trs "An error occurred while displaying this card."))]})
+    (trs "An error occurred while displaying this card.")]})
diff --git a/src/metabase/pulse/render/datetime.clj b/src/metabase/pulse/render/datetime.clj
index f8d67f7eac0c456d9752822a1d7e35306c98fe51..130078dde59aa457ae5bef55fc28db0f91e88a14 100644
--- a/src/metabase/pulse/render/datetime.clj
+++ b/src/metabase/pulse/render/datetime.clj
@@ -57,8 +57,8 @@
   [_]
   {:interval-start     (t/date-midnight (year) (month) (day))
    :interval           (t/days 1)
-   :this-interval-name (str (tru "Today"))
-   :last-interval-name (str (tru "Yesterday"))})
+   :this-interval-name (tru "Today")
+   :last-interval-name (tru "Yesterday")})
 
 (defn- start-of-this-week []
   (-> (org.joda.time.LocalDate. (t/now)) .weekOfWeekyear .roundFloorCopy .toDateTimeAtStartOfDay))
@@ -67,15 +67,15 @@
   [_]
   {:interval-start     (start-of-this-week)
    :interval           (t/weeks 1)
-   :this-interval-name (str (tru "This week"))
-   :last-interval-name (str (tru "Last week"))})
+   :this-interval-name (tru "This week")
+   :last-interval-name (tru "Last week")})
 
 (s/defmethod renderable-interval :month :- RenderableInterval
   [_]
   {:interval-start     (t/date-midnight (year) (month))
    :interval           (t/months 1)
-   :this-interval-name (str (tru "This month"))
-   :last-interval-name (str (tru "Last month"))})
+   :this-interval-name (tru "This month")
+   :last-interval-name (tru "Last month")})
 
 (defn- start-of-this-quarter []
   (t/date-midnight (year) (inc (* 3 (Math/floor (/ (dec (month))
@@ -84,15 +84,15 @@
   [_]
   {:interval-start     (start-of-this-quarter)
    :interval           (t/months 3)
-   :this-interval-name (str (tru "This quarter"))
-   :last-interval-name (str (tru "Last quarter"))})
+   :this-interval-name (tru "This quarter")
+   :last-interval-name (tru "Last quarter")})
 
 (s/defmethod renderable-interval :year :- RenderableInterval
   [_]
   {:interval-start     (t/date-midnight (year))
    :interval           (t/years 1)
-   :this-interval-name (str (tru "This year"))
-   :last-interval-name (str (tru "Last year"))})
+   :this-interval-name (tru "This year")
+   :last-interval-name (tru "Last year")})
 
 (s/defn ^:private date->interval-name :- (s/maybe su/NonBlankString)
   [date :- (s/maybe DateTime), unit :- (s/maybe s/Keyword)]
diff --git a/src/metabase/pulse/render/sparkline.clj b/src/metabase/pulse/render/sparkline.clj
index 27ea8a94223f8c1456c91f466017b8f542064e29..28533d25c0c746baa906a252fca05457c6f9405a 100644
--- a/src/metabase/pulse/render/sparkline.clj
+++ b/src/metabase/pulse/render/sparkline.clj
@@ -45,7 +45,7 @@
                    (* 2 dot-radius)))
       ;; returns `true` if successful -- see JavaDoc
       (when-not (ImageIO/write image "png" os)
-        (throw (Exception. (str (tru "No appropriate image writer found!")))))
+        (throw (Exception. (tru "No appropriate image writer found!"))))
       (.toByteArray os))))
 
 (defn- format-val-fn [timezone cols x-axis-rowfn]
diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj
index 0626c700ec1ce77fec5e1d65ce8d15896cad3418..9c0625cef173827641b9448cbc481ba695dcada6 100644
--- a/src/metabase/query_processor.clj
+++ b/src/metabase/query_processor.clj
@@ -231,7 +231,7 @@
                 ;; query failed instead of giving people a failure response and trying to get results from that. So do
                 ;; everyone a favor and throw an Exception
                 (let [results (m/dissoc-in results [:query :results-promise])]
-                  (throw (ex-info (str (tru "Error preprocessing query")) results)))))))]
+                  (throw (ex-info (tru "Error preprocessing query") results)))))))]
     (receive-native-query (build-pipeline deliver-native-query))))
 
 (defn query->preprocessed
@@ -248,12 +248,12 @@
   it."
   [{query-type :type, :as query}]
   (when-not (= query-type :query)
-    (throw (Exception. (str (tru "Can only determine expected columns for MBQL queries.")))))
+    (throw (Exception. (tru "Can only determine expected columns for MBQL queries."))))
   (let [results (qp.store/with-store
                   ((annotate/add-column-info (constantly nil))
                    (query->preprocessed query)))]
     (or (seq (:cols results))
-        (throw (ex-info (str (tru "No columns returned.")) results)))))
+        (throw (ex-info (tru "No columns returned.") results)))))
 
 (defn query->native
   "Return the native form for `query` (e.g. for a MBQL query on Postgres this would return a map containing the compiled
@@ -266,7 +266,7 @@
   (perms/check-current-user-has-adhoc-native-query-perms query)
   (let [results (preprocess query)]
     (or (get results :native)
-        (throw (ex-info (str (tru "No native form returned."))
+        (throw (ex-info (tru "No native form returned.")
                  (or results {}))))))
 
 (defn query->native-with-spliced-params
diff --git a/src/metabase/query_processor/middleware/add_implicit_clauses.clj b/src/metabase/query_processor/middleware/add_implicit_clauses.clj
index 604fb90f15ab2e9fbfc4c743a24310a6cab92710..b867548a29a0df8b6f57d3124ab9e11589ac47fd 100644
--- a/src/metabase/query_processor/middleware/add_implicit_clauses.clj
+++ b/src/metabase/query_processor/middleware/add_implicit_clauses.clj
@@ -98,8 +98,8 @@
                         [:expression (u/qualified-name expression-name)])]
       ;; if the Table has no Fields, throw an Exception, because there is no way for us to proceed
       (when-not (seq fields)
-        (throw (Exception. (str (tru "Table ''{0}'' has no Fields associated with it."
-                                     (:name (qp.store/table source-table-id)))))))
+        (throw (Exception. (tru "Table ''{0}'' has no Fields associated with it."
+                                (:name (qp.store/table source-table-id))))))
       ;; add the fields & expressions under the `:fields` clause
       (assoc inner-query :fields (vec (concat fields expressions))))))
 
diff --git a/src/metabase/query_processor/middleware/add_implicit_joins.clj b/src/metabase/query_processor/middleware/add_implicit_joins.clj
index af8a5eac9dd3901dbb1d97ee8152a41faad89fdb..066c028d2a77864fe3b34b963eb9d47888201292 100644
--- a/src/metabase/query_processor/middleware/add_implicit_joins.clj
+++ b/src/metabase/query_processor/middleware/add_implicit_joins.clj
@@ -82,8 +82,8 @@
       (doseq [dest-id dest-ids]
         (when-not (get dest->table dest-id)
           (throw
-           (ex-info (str (tru "Cannot resolve {0}: Field does not exist, or its Table belongs to a different Database."
-                              [:fk '_ dest-id]))
+            (ex-info (tru "Cannot resolve {0}: Field does not exist, or its Table belongs to a different Database."
+                          [:fk '_ dest-id])
              {:dest-id dest-id}))))
       ;; ok, we're good to go
       dest->table)))
@@ -93,9 +93,9 @@
         dest-id       (mbql.u/field-clause->id-or-literal dest-field)
         dest-table-id (dest-id->table-id dest-id)]
     (assert (and (integer? fk-id) (integer? dest-id))
-      (str (tru "Cannot resolve :field-literal inside :fk-> unless inside join with explicit :alias.")))
+      (tru "Cannot resolve :field-literal inside :fk-> unless inside join with explicit :alias."))
     (assert dest-table-id
-      (str (tru "Cannot find Table ID for {0}" dest-field)))
+      (tru "Cannot find Table ID for {0}" dest-field))
     {:fk-id fk-id, :dest-id dest-id, :dest-table-id dest-table-id}))
 
 (defn- matching-info* [infos dest-id->table-id fk-field dest-field]
@@ -108,7 +108,7 @@
           info))
       infos)
      (throw
-      (ex-info (str (tru "No matching info found."))
+      (ex-info (tru "No matching info found.")
         {:fk-id fk-id, :dest-id dest-id, :dest-table-id dest-table-id})))))
 
 (defn- matching-info-fn
@@ -125,7 +125,7 @@
         ;; add a bunch of info to any Exceptions that get thrown here, useful for debugging things that go wrong
         (catch Exception e
           (throw
-           (ex-info (str (tru "Could not resolve {0}" [:fk-> fk-field dest-field]))
+           (ex-info (tru "Could not resolve {0}" [:fk-> fk-field dest-field])
              {:clause                           [:fk-> fk-field dest-field]
               :resolved-info                    infos
               :resolved-dest-field-id->table-id dest-id->table-id}
@@ -183,7 +183,7 @@
   {:matching-info (matching-info-fn query)
    :current-alias nil
    :add-join!     (fn [join-info]
-                    (throw (ex-info (str (tru "Invalid fk-> clause: nowhere to add corresponding join."))
+                    (throw (ex-info (tru "Invalid fk-> clause: nowhere to add corresponding join.")
                              {:join-info join-info})))})
 
 (declare resolve-fk-clauses)
@@ -223,7 +223,7 @@
   (if (mbql.u/match-one (:query query) :fk->)
     (do
       (when-not (driver/supports? driver/*driver* :foreign-keys)
-        (throw (ex-info (str (tru "{0} driver does not support foreign keys." driver/*driver*))
+        (throw (ex-info (tru "{0} driver does not support foreign keys." driver/*driver*)
                  {:driver driver/*driver*})))
       (update query :query resolve-fk-clauses))
     query))
diff --git a/src/metabase/query_processor/middleware/annotate.clj b/src/metabase/query_processor/middleware/annotate.clj
index c5f448f53c8f2e1aba9e9fc634546216b5a45d39..9a2c620ff95452ec386cce25941004e0e4156db7 100644
--- a/src/metabase/query_processor/middleware/annotate.clj
+++ b/src/metabase/query_processor/middleware/annotate.clj
@@ -13,7 +13,7 @@
             [metabase.models.humanization :as humanization]
             [metabase.query-processor.store :as qp.store]
             [metabase.util
-             [i18n :refer [tru]]
+             [i18n :refer [deferred-tru tru]]
              [schema :as su]]
             [schema.core :as s]))
 
@@ -53,9 +53,9 @@
     (let [expected-count (count columns)
           actual-count   (count (first rows))]
       (when-not (= expected-count actual-count)
-        (throw (ex-info (str (tru "Query processor error: number of columns returned by driver does not match results.")
+        (throw (ex-info (str (deferred-tru "Query processor error: number of columns returned by driver does not match results.")
                              "\n"
-                             (tru "Expected {0} columns, but first row of resuls has {1} columns."
+                             (deferred-tru "Expected {0} columns, but first row of resuls has {1} columns."
                                   expected-count actual-count))
                  {:expected-columns columns
                   :first-row        (first rows)}))))))
@@ -175,7 +175,7 @@
         ;; provided so the FE can add easily add sorts and the like when someone clicks a column header
         :expression_name expression-name
         :field_ref       &match})
-      (throw (ex-info (str (tru "No expression named {0} found. Found: {1}" expression-name (keys expressions)))
+      (throw (ex-info (tru "No expression named {0} found. Found: {1}" expression-name (keys expressions))
                {:type :invalid-query, :clause &match, :expressions expressions})))
 
     [:field-id id]
@@ -188,7 +188,7 @@
 
     ;; we should never reach this if our patterns are written right so this is more to catch code mistakes than
     ;; something the user should expect to see
-    _ (throw (ex-info (str (tru "Don't know how to get information about Field:") " " &match)
+    _ (throw (ex-info (tru "Don't know how to get information about Field:" " " &match)
                {:field &match}))))
 
 
@@ -218,7 +218,7 @@
   These names are also used directly in queries, e.g. in the equivalent of a SQL `AS` clause."
   [ag-clause :- mbql.s/Aggregation & [{:keys [recursive-name-fn], :or {recursive-name-fn aggregation-name}}]]
   (when-not driver/*driver*
-    (throw (Exception. (str (tru "*driver* is unbound.")))))
+    (throw (Exception. (tru "*driver* is unbound."))))
   (mbql.u/match-one ag-clause
     [:aggregation-options _ (options :guard :name)]
     (:name options)
@@ -272,22 +272,22 @@
               (for [arg args]
                 (expression-arg-display-name (partial aggregation-arg-display-name inner-query) arg)))
 
-    [:count]             (str (tru "Count"))
-    [:distinct    arg]   (str (tru "Distinct values of {0}"  (aggregation-arg-display-name inner-query arg)))
-    [:count       arg]   (str (tru "Count of {0}"            (aggregation-arg-display-name inner-query arg)))
-    [:avg         arg]   (str (tru "Average of {0}"          (aggregation-arg-display-name inner-query arg)))
+    [:count]             (tru "Count")
+    [:distinct    arg]   (tru "Distinct values of {0}"  (aggregation-arg-display-name inner-query arg))
+    [:count       arg]   (tru "Count of {0}"            (aggregation-arg-display-name inner-query arg))
+    [:avg         arg]   (tru "Average of {0}"          (aggregation-arg-display-name inner-query arg))
     ;; cum-count and cum-sum get names for count and sum, respectively (see explanation in `aggregation-name`)
-    [:cum-count   arg]   (str (tru "Count of {0}"            (aggregation-arg-display-name inner-query arg)))
-    [:cum-sum     arg]   (str (tru "Sum of {0}"              (aggregation-arg-display-name inner-query arg)))
-    [:stddev      arg]   (str (tru "SD of {0}"               (aggregation-arg-display-name inner-query arg)))
-    [:sum         arg]   (str (tru "Sum of {0}"              (aggregation-arg-display-name inner-query arg)))
-    [:min         arg]   (str (tru "Min of {0}"              (aggregation-arg-display-name inner-query arg)))
-    [:max         arg]   (str (tru "Max of {0}"              (aggregation-arg-display-name inner-query arg)))
+    [:cum-count   arg]   (tru "Count of {0}"            (aggregation-arg-display-name inner-query arg))
+    [:cum-sum     arg]   (tru "Sum of {0}"              (aggregation-arg-display-name inner-query arg))
+    [:stddev      arg]   (tru "SD of {0}"               (aggregation-arg-display-name inner-query arg))
+    [:sum         arg]   (tru "Sum of {0}"              (aggregation-arg-display-name inner-query arg))
+    [:min         arg]   (tru "Min of {0}"              (aggregation-arg-display-name inner-query arg))
+    [:max         arg]   (tru "Max of {0}"              (aggregation-arg-display-name inner-query arg))
 
     ;; until we have a way to generate good names for filters we'll just have to say 'matching condition' for now
-    [:sum-where   arg _] (str (tru "Sum of {0} matching condition" (aggregation-arg-display-name inner-query arg)))
-    [:share       _]     (str (tru "Share of rows matching condition"))
-    [:count-where _]     (str (tru "Count of rows matching condition"))
+    [:sum-where   arg _] (tru "Sum of {0} matching condition" (aggregation-arg-display-name inner-query arg))
+    [:share       _]     (tru "Share of rows matching condition")
+    [:count-where _]     (tru "Count of rows matching condition")
 
     (_ :guard mbql.preds/Field?)
     (:display_name (col-info-for-field-clause inner-query ag-clause))
@@ -363,13 +363,13 @@
       (when-not (= expected-count actual-count)
         (throw
          (Exception.
-          (str (tru "Query processor error: mismatched number of columns in query and results.")
+          (str (deferred-tru "Query processor error: mismatched number of columns in query and results.")
                " "
-               (tru "Expected {0} fields, got {1}" expected-count actual-count)
+               (deferred-tru "Expected {0} fields, got {1}" expected-count actual-count)
                "\n"
-               (tru "Expected: {0}" (mapv :name returned-mbql-columns))
+               (deferred-tru "Expected: {0}" (mapv :name returned-mbql-columns))
                "\n"
-               (tru "Actual: {0}" (vec (:columns results))))))))))
+               (deferred-tru "Actual: {0}" (vec (:columns results))))))))))
 
 (s/defn ^:private cols-for-fields
   [{:keys [fields], :as inner-query} :- su/Map]
diff --git a/src/metabase/query_processor/middleware/async.clj b/src/metabase/query_processor/middleware/async.clj
index dbea839c424c0e9f9cd429e260d135a9ef35d655..68ab314eebab2d012082cb1face935d80a65277b 100644
--- a/src/metabase/query_processor/middleware/async.clj
+++ b/src/metabase/query_processor/middleware/async.clj
@@ -34,7 +34,7 @@
     (try
       ;; out-chan might already be closed if query was canceled. NBD if that's the case
       (a/>!! out-chan (if (nil? result)
-                        (Exception. (str (trs "Unexpectedly got `nil` Query Processor response.")))
+                        (Exception. (trs "Unexpectedly got `nil` Query Processor response."))
                         result))
       (finally
         (a/close! out-chan)))))
@@ -86,7 +86,7 @@
       (not= port out-chan)
       (do
         (a/close! out-chan)
-        (throw (TimeoutException. (str (tru "Query timed out after %s" (du/format-milliseconds query-timeout-ms))))))
+        (throw (TimeoutException. (tru "Query timed out after %s" (du/format-milliseconds query-timeout-ms)))))
 
       :else
       result)))
diff --git a/src/metabase/query_processor/middleware/async_wait.clj b/src/metabase/query_processor/middleware/async_wait.clj
index cbe8902aa292c620675a033cc6793b2c6cb15fe4..1d482506538b64525640065d8e63622a35363159 100644
--- a/src/metabase/query_processor/middleware/async_wait.clj
+++ b/src/metabase/query_processor/middleware/async_wait.clj
@@ -11,14 +11,14 @@
             [clojure.tools.logging :as log]
             [metabase.models.setting :refer [defsetting]]
             [metabase.util :as u]
-            [metabase.util.i18n :refer [trs]]
+            [metabase.util.i18n :refer [deferred-trs trs]]
             [schema.core :as s])
   (:import clojure.lang.Var
            [java.util.concurrent Executors ExecutorService]
            org.apache.commons.lang3.concurrent.BasicThreadFactory$Builder))
 
 (defsetting max-simultaneous-queries-per-db
-  (trs "Maximum number of simultaneous queries to allow per connected Database.")
+  (deferred-trs "Maximum number of simultaneous queries to allow per connected Database.")
   :type    :integer
   :default 15)
 
diff --git a/src/metabase/query_processor/middleware/binning.clj b/src/metabase/query_processor/middleware/binning.clj
index fea69126f771d7dd430bd07b86de63e8507dfe87..0e20b0810afc820d7fd8a8f927f49f15f7b2c369 100644
--- a/src/metabase/query_processor/middleware/binning.clj
+++ b/src/metabase/query_processor/middleware/binning.clj
@@ -48,7 +48,7 @@
                                                  (apply min user-maxes))
                                                global-max)]
     (when-not (and min-value max-value)
-      (throw (ex-info (str (tru "Unable to bin Field without a min/max value"))
+      (throw (ex-info (tru "Unable to bin Field without a min/max value")
                {:field-id field-id, :fingerprint fingerprint})))
     {:min-value min-value, :max-value max-value}))
 
@@ -167,7 +167,7 @@
     (do
       ;; make sure source-metadata exists
       (when-not source-metadata
-        (throw (ex-info (str (tru "Cannot update binned field: query is missing source-metadata"))
+        (throw (ex-info (tru "Cannot update binned field: query is missing source-metadata")
                  {:field-literal field-id-or-name})))
       ;; try to find field in source-metadata with matching name
       (or
@@ -176,8 +176,8 @@
           (when (= (:name metadata) field-id-or-name)
             metadata))
         source-metadata)
-       (throw (ex-info (str (tru "Cannot update binned field: could not find matching source metadata for Field ''{0}''"
-                                 field-id-or-name))
+       (throw (ex-info (tru "Cannot update binned field: could not find matching source metadata for Field ''{0}''"
+                            field-id-or-name)
                 {:field-literal field-id-or-name, :resolved-metadata source-metadata}))))))
 
 (s/defn ^:private update-binned-field :- mbql.s/binning-strategy
diff --git a/src/metabase/query_processor/middleware/check_features.clj b/src/metabase/query_processor/middleware/check_features.clj
index f908b4316a9da1fc67932839e2b168d0176cdfcc..40a29712eca86e8cd29333f68afd388df95ca0f9 100644
--- a/src/metabase/query_processor/middleware/check_features.clj
+++ b/src/metabase/query_processor/middleware/check_features.clj
@@ -12,7 +12,7 @@
   [feature]
   (when driver/*driver*
     (when-not (driver/supports? driver/*driver* feature)
-      (throw (Exception. (str (tru "{0} is not supported by this driver." (name feature))))))))
+      (throw (Exception. (tru "{0} is not supported by this driver." (name feature)))))))
 
 ;; TODO - definitely a little incomplete. It would be cool if we cool look at the metadata in the schema namespace and
 ;; auto-generate this logic
diff --git a/src/metabase/query_processor/middleware/expand_macros.clj b/src/metabase/query_processor/middleware/expand_macros.clj
index df2cfb9cb44fc71e88d5b6cfa071a5302ba66ad4..fd45a4e9664deb9e3e698e64dd79629411d93db8 100644
--- a/src/metabase/query_processor/middleware/expand_macros.clj
+++ b/src/metabase/query_processor/middleware/expand_macros.clj
@@ -14,8 +14,9 @@
              [metric :refer [Metric]]
              [segment :refer [Segment]]]
             [metabase.util :as u]
-            [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]))
 
@@ -31,7 +32,7 @@
   (mbql.u/replace-in outer-query [:query]
     [:segment (segment-id :guard (complement mbql.u/ga-id?))]
     (or (:filter (segment-id->definition segment-id))
-        (throw (IllegalArgumentException. (str (tru "Segment {0} does not exist, or is invalid." segment-id)))))))
+        (throw (IllegalArgumentException. (tru "Segment {0} does not exist, or is invalid." segment-id))))))
 
 (s/defn ^:private expand-segments :- mbql.s/Query
   [{inner-query :query, :as outer-query} :- mbql.s/Query]
@@ -96,7 +97,7 @@
 (defn- replace-metrics-aggregations [query metric-id->info]
   (let [metric (fn [metric-id]
                  (or (get metric-id->info metric-id)
-                     (throw (ex-info (str (tru "Metric {0} does not exist, or is invalid." metric-id))
+                     (throw (ex-info (tru "Metric {0} does not exist, or is invalid." metric-id)
                               {:type :invalid-query, :metric metric-id}))))]
     (mbql.u/replace-in query [:query]
       ;; if metric is wrapped in aggregation options that give it a display name, expand the metric but do not name it
diff --git a/src/metabase/query_processor/middleware/fetch_source_query.clj b/src/metabase/query_processor/middleware/fetch_source_query.clj
index e292b47f76ad74ba419b99097e494dfe8c77c22b..f0a559e54861da16e4c2a68e63ae109d833afa94 100644
--- a/src/metabase/query_processor/middleware/fetch_source_query.clj
+++ b/src/metabase/query_processor/middleware/fetch_source_query.clj
@@ -94,7 +94,7 @@
   [card-id :- su/IntGreaterThanZero]
   (let [card
         (or (db/select-one [Card :dataset_query :database_id :result_metadata] :id card-id)
-            (throw (ex-info (str (tru "Card {0} does not exist." card-id))
+            (throw (ex-info (tru "Card {0} does not exist." card-id)
                      {:card-id card-id})))
 
         {{mbql-query                     :query
@@ -109,7 +109,7 @@
             (when native-query
               (cond-> {:native (trim-query card-id native-query)}
                 (seq template-tags) (assoc :template-tags template-tags)))
-            (throw (ex-info (str (tru "Missing source query in Card {0}" card-id))
+            (throw (ex-info (tru "Missing source query in Card {0}" card-id)
                      {:card card})))]
     ;; log the query at this point, it's useful for some purposes
     ;;
diff --git a/src/metabase/query_processor/middleware/parameters/sql.clj b/src/metabase/query_processor/middleware/parameters/sql.clj
index 6716baf966698ccdc2053a088219a6f7e75d2da3..b327c8e02a67d519f51cddc12e37a42d826f14fd 100644
--- a/src/metabase/query_processor/middleware/parameters/sql.clj
+++ b/src/metabase/query_processor/middleware/parameters/sql.clj
@@ -155,7 +155,7 @@
   "Return the default value for a Dimension (Field Filter) param defined by the map TAG, if one is set."
   [tag :- TagParam]
   (when (and (:required tag) (not (:default tag)))
-    (throw (Exception. (str (tru "''{0}'' is a required param." (:display-name tag))))))
+    (throw (Exception. (tru "''{0}'' is a required param." (:display-name tag)))))
   (when-let [default (:default tag)]
     {:type   (:widget-type tag :dimension)             ; widget-type is the actual type of the default value if set
      :target [:dimension [:template-tag (:name tag)]]
@@ -172,8 +172,8 @@
   (when-let [dimension (:dimension tag)]
     (map->Dimension {:field (or (db/select-one [Field :name :parent_id :table_id :base_type],
                                   :id (dimension->field-id dimension))
-                                (throw (Exception. (str (tru "Can't find field with ID: {0}"
-                                                             (dimension->field-id dimension))))))
+                                (throw (Exception. (tru "Can't find field with ID: {0}"
+                                                        (dimension->field-id dimension)))))
                      :param (or
                              ;; look in the sequence of params we were passed to see if there's anything that matches
                              (param-with-target params [:dimension [:template-tag (:name tag)]])
@@ -194,7 +194,7 @@
   [{:keys [default display-name required]} :- TagParam]
   (or default
       (when required
-        (throw (Exception. (str (tru "''{0}'' is a required param." display-name)))))))
+        (throw (Exception. (tru "''{0}'' is a required param." display-name))))))
 
 
 ;;; Parsing Values
@@ -493,8 +493,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)
-                                (str (tru "Found ''{0}'' with no terminating ''{1}'' in query ''{2}''"
-                                          delimited-begin delimited-end s)))]
+                                (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
diff --git a/src/metabase/query_processor/middleware/permissions.clj b/src/metabase/query_processor/middleware/permissions.clj
index 10119dc8baf18f3b55729238648df6a2ee6f971b..743bd531b47d030c1c8e51b4e84f597cfedf609f 100644
--- a/src/metabase/query_processor/middleware/permissions.clj
+++ b/src/metabase/query_processor/middleware/permissions.clj
@@ -16,11 +16,11 @@
   "Check that the current user has permissions to read Card with `card-id`, or throw an Exception. "
   [card-id :- su/IntGreaterThanZero]
   (when-not (mi/can-read? (or (db/select-one [Card :collection_id] :id card-id)
-                              (throw (Exception. (str (tru "Card {0} does not exist." card-id))))))
-    (throw (Exception. (str (tru "You do not have permissions to view Card {0}." card-id))))))
+                              (throw (Exception. (tru "Card {0} does not exist." card-id)))))
+    (throw (Exception. (tru "You do not have permissions to view Card {0}." card-id)))))
 
 (defn- perms-exception [required-perms]
-  (ex-info (str (tru "You do not have permissions to run this query."))
+  (ex-info (tru "You do not have permissions to run this query.")
     {:required-permissions required-perms
      :actual-permissions   @*current-user-permissions-set*
      :permissions-error?   true}))
diff --git a/src/metabase/query_processor/middleware/process_userland_query.clj b/src/metabase/query_processor/middleware/process_userland_query.clj
index 3ea6525f9da87892f8f0c44caa36095e83f65f70..89047b931eed5bf48fdad7c8ef9846d402c0e314 100644
--- a/src/metabase/query_processor/middleware/process_userland_query.clj
+++ b/src/metabase/query_processor/middleware/process_userland_query.clj
@@ -13,7 +13,7 @@
             [metabase.util :as u]
             [metabase.util
              [date :as du]
-             [i18n :refer [trs tru]]]
+             [i18n :refer [deferred-tru trs tru]]]
             [toucan.db :as db]))
 
 (defn- add-running-time [{start-time-ms :start_time_millis, :as query-execution}]
@@ -92,10 +92,10 @@
     ;; if the result itself is invalid there's something wrong in the QP -- not just with the query. Pass an
     ;; Exception up to the top-level handler; this is basically a 500 situation
     (nil? result)
-    (raise (Exception. (str (trs "Unexpected nil response from query processor."))))
+    (raise (Exception. (trs "Unexpected nil response from query processor.")))
 
     (not status)
-    (raise (Exception. (str (tru "Invalid response from database driver. No :status provided.")
+    (raise (Exception. (str (deferred-tru "Invalid response from database driver. No :status provided.")
                             " "
                             result)))
 
diff --git a/src/metabase/query_processor/middleware/resolve_driver.clj b/src/metabase/query_processor/middleware/resolve_driver.clj
index d7d5ce32ef7e29f53d9a35b11d12be22853a6476..3ee7d298c672d4b83ef5c8437744c4c1c9d2bb27 100644
--- a/src/metabase/query_processor/middleware/resolve_driver.clj
+++ b/src/metabase/query_processor/middleware/resolve_driver.clj
@@ -12,12 +12,11 @@
     ;; Make sure the `:database` key is present and that it's a positive int (the nested query placeholder should have
     ;; been replaced by relevant middleware by now)
     (when-not ((every-pred integer? pos?) database)
-      (throw (ex-info (str (tru "Unable to resolve driver for query: missing or invalid `:database` ID."))
+      (throw (ex-info (tru "Unable to resolve driver for query: missing or invalid `:database` ID.")
                {:database database})))
     ;; ok, now make sure the Database exists in the DB
     (let [driver (or (driver.u/database->driver database)
-                     (throw (ex-info (str (tru "Unable to resolve driver for query: Database {0} does not exist."
-                                               database))
+                     (throw (ex-info (tru "Unable to resolve driver for query: Database {0} does not exist." database)
                               {:database database})))]
       (binding [driver/*driver* driver]
         (qp (assoc query :driver driver))))))
diff --git a/src/metabase/query_processor/middleware/resolve_joins.clj b/src/metabase/query_processor/middleware/resolve_joins.clj
index 02befe86aef98355eccd75a7fd8db08ab53937fb..61c6f7a992b085dae1f53af784ffd033ce401837 100644
--- a/src/metabase/query_processor/middleware/resolve_joins.clj
+++ b/src/metabase/query_processor/middleware/resolve_joins.clj
@@ -63,7 +63,7 @@
 
 (defn- source-metadata->fields [{:keys [alias], :as join} source-metadata]
   (when-not (seq source-metadata)
-    (throw (ex-info (str (tru "Cannot use :fields :all in join against source query unless it has :source-metadata."))
+    (throw (ex-info (tru "Cannot use :fields :all in join against source query unless it has :source-metadata.")
              {:join join})))
   (for [{field-name :name, base-type :base_type} source-metadata]
     [:joined-field alias [:field-literal field-name base-type]]))
@@ -136,8 +136,8 @@
       (when-not (aliases alias)
         (throw
          (IllegalArgumentException.
-          (str (tru "Bad :joined-field clause: join with alias ''{0}'' does not exist. Found: {1}"
-                    alias aliases))))))))
+           (tru "Bad :joined-field clause: join with alias ''{0}'' does not exist. Found: {1}"
+                alias aliases)))))))
 
 (s/defn ^:private resolve-joins-in-mbql-query :- ResolvedMBQLQuery
   [{:keys [joins], :as query} :- mbql.s/MBQLQuery]
diff --git a/src/metabase/query_processor/middleware/resolve_source_table.clj b/src/metabase/query_processor/middleware/resolve_source_table.clj
index 0928a0d889265a3cdb85a8b054c4e80f1cca721b..efd819cf83b663e9be0997640c42773535839212 100644
--- a/src/metabase/query_processor/middleware/resolve_source_table.clj
+++ b/src/metabase/query_processor/middleware/resolve_source_table.clj
@@ -16,9 +16,9 @@
   (mbql.u/match-one query
     (m :guard (every-pred map? :source-table (comp (complement positive-int?) :source-table)))
     (throw
-     (ex-info
-         (str (tru "Invalid :source-table ''{0}'': should be resolved to a Table ID by now." (:source-table m)))
-       {:form m}))))
+      (ex-info
+        (tru "Invalid :source-table ''{0}'': should be resolved to a Table ID by now." (:source-table m))
+        {:form m}))))
 
 (s/defn ^:private query->source-table-ids :- (s/maybe (su/non-empty #{su/IntGreaterThanZero}))
   "Fetch a set of all `:source-table` IDs anywhere in `query`."
diff --git a/src/metabase/query_processor/store.clj b/src/metabase/query_processor/store.clj
index ac3fa90bec8313e6f771047da1b961e2bde31241..802e6637c0a3b42003a2388570deacba866b1abb 100644
--- a/src/metabase/query_processor/store.clj
+++ b/src/metabase/query_processor/store.clj
@@ -26,7 +26,7 @@
 ;;; ---------------------------------------------- Setting up the Store ----------------------------------------------
 
 (def ^:private uninitialized-store
-  (delay (throw (Exception. (str (tru "Error: Query Processor store is not initialized."))))))
+  (delay (throw (Exception. (tru "Error: Query Processor store is not initialized.")))))
 
 (def ^:private ^:dynamic *store*
   "Dynamic var used as the QP store for a given query execution."
@@ -140,7 +140,7 @@
 (s/defn ^:private db-id :- su/IntGreaterThanZero
   []
   (or (get-in @*store* [:database :id])
-      (throw (Exception. (str (tru "Cannot store Tables or Fields before Database is stored."))))))
+      (throw (Exception. (tru "Cannot store Tables or Fields before Database is stored.")))))
 
 (s/defn fetch-and-store-database!
   "Fetch the Database this query will run against from the application database, and store it in the QP Store for the
@@ -150,12 +150,12 @@
   (if-let [existing-db-id (get-in @*store* [:database :id])]
     ;; if there's already a DB in the Store, double-check it has the same ID as the one that we were asked to fetch
     (when-not (= existing-db-id database-id)
-      (throw (ex-info (str (tru "Attempting to fetch second Database. Queries can only reference one Database."))
+      (throw (ex-info (tru "Attempting to fetch second Database. Queries can only reference one Database.")
                {:existing-id existing-db-id, :attempted-to-fetch database-id})))
     ;; if there's no DB, fetch + save
     (store-database!
      (or (db/select-one (into [Database] database-columns-to-fetch) :id database-id)
-         (throw (ex-info (str (tru "Database {0} does not exist." (str database-id)))
+         (throw (ex-info (tru "Database {0} does not exist." (str database-id))
                   {:database database-id}))))))
 
 (def ^:private IDs
@@ -179,7 +179,7 @@
       (doseq [id ids-to-fetch]
         (when-not (fetched-ids id)
           (throw
-           (ex-info (str (tru "Failed to fetch Table {0}: Table does not exist, or belongs to a different Database." id))
+           (ex-info (tru "Failed to fetch Table {0}: Table does not exist, or belongs to a different Database." id)
              {:table id, :database (db-id)}))))
       ;; ok, now store them all in the Store
       (doseq [table fetched-tables]
@@ -207,7 +207,7 @@
       (doseq [id ids-to-fetch]
         (when-not (fetched-ids id)
           (throw
-           (ex-info (str (tru "Failed to fetch Field {0}: Field does not exist, or belongs to a different Database." id))
+           (ex-info (tru "Failed to fetch Field {0}: Field does not exist, or belongs to a different Database." id)
              {:field id, :database (db-id)}))))
       ;; ok, now store them all in the Store
       (doseq [field fetched-fields]
@@ -221,16 +221,16 @@
   returned."
   []
   (or (:database @*store*)
-      (throw (Exception. (str (tru "Error: Database is not present in the Query Processor Store."))))))
+      (throw (Exception. (tru "Error: Database is not present in the Query Processor Store.")))))
 
 (s/defn table :- TableInstanceWithRequiredStoreKeys
   "Fetch Table with `table-id` from the QP Store. Throws an Exception if valid item is not returned."
   [table-id :- su/IntGreaterThanZero]
   (or (get-in @*store* [:tables table-id])
-      (throw (Exception. (str (tru "Error: Table {0} is not present in the Query Processor Store." table-id))))))
+      (throw (Exception. (tru "Error: Table {0} is not present in the Query Processor Store." table-id)))))
 
 (s/defn field :- FieldInstanceWithRequiredStorekeys
   "Fetch Field with `field-id` from the QP Store. Throws an Exception if valid item is not returned."
   [field-id :- su/IntGreaterThanZero]
   (or (get-in @*store* [:fields field-id])
-      (throw (Exception. (str (tru "Error: Field {0} is not present in the Query Processor Store." field-id))))))
+      (throw (Exception. (tru "Error: Field {0} is not present in the Query Processor Store." field-id)))))
diff --git a/src/metabase/routes/index.clj b/src/metabase/routes/index.clj
index 864b2f87f8ee7382a99f3fe9ebc14579d7884bf8..29d94d7203a11c60dae3b45a7b342bd67965104c 100644
--- a/src/metabase/routes/index.clj
+++ b/src/metabase/routes/index.clj
@@ -62,7 +62,7 @@
   (try
     (stencil/render-file path variables)
     (catch IllegalArgumentException e
-      (let [message (str (trs "Failed to load template ''{0}''. Did you remember to build the Metabase frontend?" path))]
+      (let [message (trs "Failed to load template ''{0}''. Did you remember to build the Metabase frontend?" path)]
         (log/error e message)
         (throw (Exception. message e))))))
 
diff --git a/src/metabase/sample_data.clj b/src/metabase/sample_data.clj
index def87d8516df61500aef1f5da6265b5ccdd62fbf..4b27b6fc4a459d06108037534fa5a88fb125fb5b 100644
--- a/src/metabase/sample_data.clj
+++ b/src/metabase/sample_data.clj
@@ -13,8 +13,8 @@
 (defn- db-details []
   (let [resource (io/resource sample-dataset-filename)]
     (when-not resource
-      (throw (Exception. (str (trs "Sample dataset DB file ''{0}'' cannot be found."
-                                   sample-dataset-filename)))))
+      (throw (Exception. (trs "Sample dataset DB file ''{0}'' cannot be found."
+                              sample-dataset-filename))))
     {:db (-> (.getPath resource)
              (str/replace #"^file:" "zip:") ; to connect to an H2 DB inside a JAR just replace file: with zip: (this doesn't do anything when running from `lein`, which has no `file:` prefix)
              (str/replace #"\.mv\.db$" "")  ; strip the .mv.db suffix from the path
diff --git a/src/metabase/sync/analyze/fingerprint/fingerprinters.clj b/src/metabase/sync/analyze/fingerprint/fingerprinters.clj
index ed2b27b0ab3131a7e5cf9bf5e4f412a984089c22..4d2494816ba0fd27350f19722bf7125f128b0a80 100644
--- a/src/metabase/sync/analyze/fingerprint/fingerprinters.clj
+++ b/src/metabase/sync/analyze/fingerprint/fingerprinters.clj
@@ -128,7 +128,7 @@
             ~transducer
             (fn [fingerprint#]
               {:type {~(first field-type) fingerprint#}})))
-         (str (trs "Error generating fingerprint for {0}" (sync-util/name-for-logging field#)))))))
+         (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/util.clj b/src/metabase/sync/util.clj
index 12a561fe521e0c2c622684d4e0cb8171480179c5..31a5deea9df9c69a06f23353f141c95239e9a945 100644
--- a/src/metabase/sync/util.clj
+++ b/src/metabase/sync/util.clj
@@ -263,19 +263,19 @@
 (extend-protocol INameForLogging
   i/DatabaseInstance
   (name-for-logging [{database-name :name, id :id, engine :engine,}]
-    (str (trs "{0} Database {1} ''{2}''" (name engine) (or id "") database-name)))
+    (trs "{0} Database {1} ''{2}''" (name engine) (or id "") database-name))
 
   i/TableInstance
   (name-for-logging [{schema :schema, id :id, table-name :name}]
-    (str (trs "Table {0} ''{1}''" (or id "") (str (when (seq schema) (str schema ".")) table-name))))
+    (trs "Table {0} ''{1}''" (or id "") (str (when (seq schema) (str schema ".")) table-name)))
 
   i/FieldInstance
   (name-for-logging [{field-name :name, id :id}]
-    (str (trs "Field {0} ''{1}''" (or id "") field-name)))
+    (trs "Field {0} ''{1}''" (or id "") field-name))
 
   i/ResultColumnMetadataInstance
   (name-for-logging [{field-name :name}]
-    (str (trs "Field ''{0}''" field-name))))
+    (trs "Field ''{0}''" field-name)))
 
 (defn calculate-hash
   "Calculate a cryptographic hash on `clj-data` and return that hash as a string"
@@ -350,9 +350,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 (str (trs "step ''{0}'' for {1}"
-                                                                  step-name
-                                                                  (name-for-logging database)))
+        results    (with-start-and-finish-debug-logging (trs "step ''{0}'' for {1}"
+                                                             step-name
+                                                             (name-for-logging database))
                      #(sync-fn database))
         end-time   (time/now)]
     [step-name (assoc results
@@ -373,10 +373,10 @@
                "# %s\n"
                "# %s\n"
                "# %s\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))]))
+          [(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]
                 (apply format (str "# ---------------------------------------------------------------\n"
                                    "# %s\n"
@@ -385,10 +385,10 @@
                                    "# %s\n"
                                    (when log-summary-fn
                                        (format "# %s\n" (log-summary-fn step-info))))
-                       (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))]))))
+                       [(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"))
 
 (s/defn ^:private  log-sync-summary
diff --git a/src/metabase/util.clj b/src/metabase/util.clj
index 7105354a637c685e908830b3337ccc59e0cdfbfe..4cf47428396a5468795f832588c77f62c0daaf4b 100644
--- a/src/metabase/util.clj
+++ b/src/metabase/util.clj
@@ -322,7 +322,7 @@
     (when (= result ::timeout)
       (when (instance? java.util.concurrent.Future reff)
         (future-cancel reff))
-      (throw (TimeoutException. (str (tru "Timed out after {0} milliseconds." timeout-ms)))))
+      (throw (TimeoutException. (tru "Timed out after {0} milliseconds." timeout-ms))))
     result))
 
 (defn do-with-timeout
@@ -486,7 +486,7 @@
   ;; TODO - lots of functions can be rewritten to use this, which would make them more flexible
   ^Integer [object-or-id]
   (or (id object-or-id)
-      (throw (Exception. (str (tru "Not something with an ID: {0}" object-or-id))))))
+      (throw (Exception. (tru "Not something with an ID: {0}" object-or-id)))))
 
 (def metabase-namespace-symbols
   "Delay to a vector of symbols of all Metabase namespaces, excluding test namespaces.
diff --git a/src/metabase/util/date.clj b/src/metabase/util/date.clj
index caae7aa17b3291150f403f3e8b2b0c05399e0f65..6a1051ed16d73cd2bcb50010eeaee361ccdd89bc 100644
--- a/src/metabase/util/date.clj
+++ b/src/metabase/util/date.clj
@@ -8,7 +8,7 @@
             [clojure.tools.logging :as log]
             [metabase.util :as u]
             [metabase.util
-             [i18n :refer [trs]]
+             [i18n :refer [deferred-trs]]
              [schema :as su]]
             [schema.core :as s])
   (:import clojure.lang.Keyword
@@ -60,19 +60,19 @@
         ;; match, we should suggest that the user configure a report timezone
         (when (and (not report-timezone)
                    jvm-data-tz-conflict?)
-          (log/warn (str (trs "Possible timezone conflict found on database {0}." (:name db))
+          (log/warn (str (deferred-trs "Possible timezone conflict found on database {0}." (:name db))
                          " "
-                         (trs "JVM timezone is {0} and detected database timezone is {1}."
-                              (.getID jvm-timezone) (.getID data-timezone))
+                         (deferred-trs "JVM timezone is {0} and detected database timezone is {1}."
+                                   (.getID jvm-timezone) (.getID data-timezone))
                          " "
-                         (trs "Configure a report timezone to ensure proper date and time conversions."))))
+                         (deferred-trs "Configure a report timezone to ensure proper date and time conversions."))))
         ;; This database doesn't support a report timezone, check the JVM and data timezones, if they don't match,
         ;; warn the user
         (when jvm-data-tz-conflict?
-          (log/warn (str (trs "Possible timezone conflict found on database {0}." (:name db))
+          (log/warn (str (deferred-trs "Possible timezone conflict found on database {0}." (:name db))
                          " "
-                         (trs "JVM timezone is {0} and detected database timezone is {1}."
-                              (.getID jvm-timezone) (.getID data-timezone)))))))))
+                         (deferred-trs "JVM timezone is {0} and detected database timezone is {1}."
+                                   (.getID jvm-timezone) (.getID data-timezone)))))))))
 
 (defn call-with-effective-timezone
   "Invokes `f` with `*report-timezone*` and `*data-timezone*` bound for the given `db`"
diff --git a/src/metabase/util/embed.clj b/src/metabase/util/embed.clj
index 9185499e722394d2e6455e5cdf201652201eb4dd..678e7506c2fd2fce98b3b1eef933b88c1dcb710f 100644
--- a/src/metabase/util/embed.clj
+++ b/src/metabase/util/embed.clj
@@ -9,7 +9,7 @@
              [public-settings :as public-settings]
              [util :as u]]
             [metabase.models.setting :as setting]
-            [metabase.util.i18n :refer [trs tru]]
+            [metabase.util.i18n :refer [deferred-tru trs tru]]
             [ring.util.codec :as codec]))
 
 ;;; --------------------------------------------- PUBLIC LINKS UTIL FNS ----------------------------------------------
@@ -54,7 +54,7 @@
 ;;; ----------------------------------------------- EMBEDDING UTIL FNS -----------------------------------------------
 
 (setting/defsetting ^:private embedding-secret-key
-  (tru "Secret key used to sign JSON Web Tokens for requests to `/api/embed` endpoints.")
+  (deferred-tru "Secret key used to sign JSON Web Tokens for requests to `/api/embed` endpoints.")
   :setter (fn [new-value]
             (when (seq new-value)
               (assert (u/hexadecimal-string? new-value)
@@ -74,9 +74,9 @@
   [^String message]
   (let [{:keys [alg]} (jwt-header message)]
     (when-not alg
-      (throw (Exception. (str (trs "JWT is missing `alg`.")))))
+      (throw (Exception. (trs "JWT is missing `alg`."))))
     (when (= alg "none")
-      (throw (Exception. (str (trs "JWT `alg` cannot be `none`.")))))))
+      (throw (Exception. (trs "JWT `alg` cannot be `none`."))))))
 
 (defn unsign
   "Parse a \"signed\" (base-64 encoded) JWT and return a Clojure representation. Check that the signature is
@@ -88,7 +88,7 @@
       (check-valid-alg message)
       (jwt/unsign message
                   (or (embedding-secret-key)
-                      (throw (ex-info (str (tru "The embedding secret key has not been set.")) {:status-code 400})))
+                      (throw (ex-info (tru "The embedding secret key has not been set.") {:status-code 400})))
                   ;; The library will reject tokens with a created at timestamp in the future, so to account for clock
                   ;; skew tell the library to allow for 60 seconds of leeway
                   {:leeway 60})
@@ -100,4 +100,4 @@
   "Find KEYSEQ in the UNSIGNED-TOKEN (a JWT token decoded by `unsign`) or throw a 400."
   [unsigned-token keyseq]
   (or (get-in unsigned-token keyseq)
-      (throw (ex-info (str (tru "Token is missing value for keypath") " " keyseq) {:status-code 400}))))
+      (throw (ex-info (tru "Token is missing value for keypath" " " keyseq) {:status-code 400}))))
diff --git a/src/metabase/util/files.clj b/src/metabase/util/files.clj
index dfe87d20aa9a401b3b29ee2f96b84da45ebf7916..0a79cab2e10e41e85fc7090ac3e3016451242c8e 100644
--- a/src/metabase/util/files.clj
+++ b/src/metabase/util/files.clj
@@ -105,7 +105,7 @@
   [^String resource, f]
   (let [url (io/resource resource)]
     (when-not url
-      (throw (FileNotFoundException. (str (trs "Resource does not exist.")))))
+      (throw (FileNotFoundException. (trs "Resource does not exist."))))
     (if (url-inside-jar? url)
       (with-open [fs (jar-file-system-from-url url)]
         (f (get-path-in-filesystem fs "/" resource)))
diff --git a/src/metabase/util/i18n.clj b/src/metabase/util/i18n.clj
index 05728b78001f29c6692acd4f28db23db1c29bfc1..a89718d19eeb0f22e5e43aad960ff10a67ce5956 100644
--- a/src/metabase/util/i18n.clj
+++ b/src/metabase/util/i18n.clj
@@ -62,7 +62,7 @@
   "Schema for user and system localized string instances"
   (s/cond-pre UserLocalizedString SystemLocalizedString))
 
-(defmacro tru
+(defmacro deferred-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
@@ -70,7 +70,7 @@
   [msg & args]
   `(UserLocalizedString. ~(namespace-munge *ns*) ~msg ~(vec args)))
 
-(defmacro trs
+(defmacro deferred-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
@@ -78,6 +78,27 @@
   [msg & args]
   `(SystemLocalizedString. ~(namespace-munge *ns*) ~msg ~(vec args)))
 
+(def ^String str*
+  "Ensures that `trs`/`tru` isn't called prematurely, during compilation."
+  (if *compile-files*
+    (fn [& _]
+      (throw (Exception. "Premature i18n string lookup. Is there a top-level call to `trs` or `tru`?")))
+    str))
+
+(defmacro tru
+  "Applies `str` to `deferred-tru`'s expansion.
+  Prefer this over `deferred-tru`. Use `deferred-tru` only in code executed at compile time, or where `str` is manually
+  applied to the result."
+  [msg & args]
+  `(str* (deferred-tru ~msg ~@args)))
+
+(defmacro trs
+  "Applies `str` to `deferred-trs`'s expansion.
+  Prefer this over `deferred-trs`. Use `deferred-trs` only in code executed at compile time, or where `str` is manually
+  applied to the result."
+  [msg & args]
+  `(str* (deferred-trs ~msg ~@args)))
+
 (def ^:private localized-string-checker
   "Compiled checker for `LocalizedString`s which is more efficient when used repeatedly like in `localized-string?`
   below"
diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj
index d64cdd47505e6a75fdb5ce5733333d2735f2c494..9e14a327d640a0c859b67b5ffe72c491e69a730c 100644
--- a/src/metabase/util/schema.clj
+++ b/src/metabase/util/schema.clj
@@ -6,7 +6,7 @@
             [medley.core :as m]
             [metabase.util :as u]
             [metabase.util
-             [i18n :refer [tru]]
+             [i18n :refer [deferred-tru]]
              [password :as password]]
             [schema
              [core :as s]
@@ -66,16 +66,17 @@
    These are used as a fallback by API param validation if no value for `:api-error-message` is present."
   [existing-schema]
   (cond
-    (= existing-schema s/Int)                           (tru "value must be an integer.")
-    (= existing-schema s/Str)                           (tru "value must be a string.")
-    (= existing-schema s/Bool)                          (tru "value must be a boolean.")
-    (instance? java.util.regex.Pattern existing-schema) (tru "value must be a string that matches the regex `{0}`."
-                                                             existing-schema)))
+    (= existing-schema s/Int)                           (deferred-tru "value must be an integer.")
+    (= existing-schema s/Str)                           (deferred-tru "value must be a string.")
+    (= existing-schema s/Bool)                          (deferred-tru "value must be a boolean.")
+    (instance? java.util.regex.Pattern existing-schema) (deferred-tru
+                                                          "value must be a string that matches the regex `{0}`."
+                                                          existing-schema)))
 
 (declare api-error-message)
 
 (defn- create-cond-schema-message [child-schemas]
-  (str (tru "value must satisfy one of the following requirements: ")
+  (str (deferred-tru "value must satisfy one of the following requirements: ")
        (str/join " " (for [[i child-schema] (m/indexed child-schemas)]
                        (format "%d) %s" (inc i) (api-error-message child-schema))))))
 
@@ -93,11 +94,11 @@
       ;; "value may be nil, or if non-nil, value must be ..."
       (when (instance? schema.core.Maybe schema)
         (when-let [message (api-error-message (:schema schema))]
-          (tru "value may be nil, or if non-nil, {0}" message)))
+          (deferred-tru "value may be nil, or if non-nil, {0}" message)))
       ;; we can do something similar for enum schemas which are also likely to be defined inline
       (when (instance? schema.core.EnumSchema schema)
-        (tru "value must be one of: {0}." (str/join ", " (for [v (sort (:vs schema))]
-                                                           (str "`" v "`")))))
+        (deferred-tru "value must be one of: {0}." (str/join ", " (for [v (sort (:vs schema))]
+                                                                    (str "`" v "`")))))
       ;; For cond-pre schemas we'll generate something like
       ;; value must satisfy one of the following requirements:
       ;; 1) value must be a boolean.
@@ -111,9 +112,9 @@
 
       ;; do the same for sequences of a schema
       (when (vector? schema)
-        (str (tru "value must be an array.") (when (= (count schema) 1)
-                                               (when-let [message (api-error-message (first schema))]
-                                                 (str " " (tru "Each {0}" message))))))))
+        (str (deferred-tru "value must be an array.") (when (= (count schema) 1)
+                                                        (when-let [message (api-error-message (first schema))]
+                                                          (str " " (deferred-tru "Each {0}" message))))))))
 
 
 (defn non-empty
@@ -121,7 +122,7 @@
    (i.e., it must satisfy `seq`)."
   [schema]
   (with-api-error-message (s/constrained schema seq "Non-empty")
-    (str (api-error-message schema) " " (tru "The array cannot be empty."))))
+    (str (api-error-message schema) " " (deferred-tru "The array cannot be empty."))))
 
 (defn empty-or-distinct?
   "True if `coll` is either empty or distinct."
@@ -134,7 +135,7 @@
   "Add an additional constraint to `schema` (presumably an array) that requires all elements to be distinct."
   [schema]
   (with-api-error-message (s/constrained schema empty-or-distinct? "distinct")
-    (str (api-error-message schema) " " (tru "All elements must be distinct."))))
+    (str (api-error-message schema) " " (deferred-tru "All elements must be distinct."))))
 
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
@@ -144,86 +145,86 @@
 (def NonBlankString
   "Schema for a string that cannot be blank."
   (with-api-error-message (s/constrained s/Str (complement str/blank?) "Non-blank string")
-    (tru "value must be a non-blank string.")))
+    (deferred-tru "value must be a non-blank string.")))
 
 (def IntGreaterThanOrEqualToZero
   "Schema representing an integer than must also be greater than or equal to zero."
   (with-api-error-message
-      (s/constrained s/Int (partial <= 0) (tru "Integer greater than or equal to zero"))
-    (tru "value must be an integer greater than or equal to zero.")))
+      (s/constrained s/Int (partial <= 0) (deferred-tru "Integer greater than or equal to zero"))
+    (deferred-tru "value must be an integer greater than or equal to zero.")))
 
 ;; TODO - rename this to `PositiveInt`?
 (def IntGreaterThanZero
   "Schema representing an integer than must also be greater than zero."
   (with-api-error-message
-      (s/constrained s/Int (partial < 0) (tru "Integer greater than zero"))
-    (tru "value must be an integer greater than zero.")))
+      (s/constrained s/Int (partial < 0) (deferred-tru "Integer greater than zero"))
+    (deferred-tru "value must be an integer greater than zero.")))
 
 (def NonNegativeInt
   "Schema representing an integer 0 or greater"
   (with-api-error-message
-      (s/constrained s/Int (partial <= 0) (tru "Integer greater than or equal to zero"))
-    (tru "value must be an integer zero or greater.")))
+      (s/constrained s/Int (partial <= 0) (deferred-tru "Integer greater than or equal to zero"))
+    (deferred-tru "value must be an integer zero or greater.")))
 
 (def PositiveNum
   "Schema representing a numeric value greater than zero. This allows floating point numbers and integers."
   (with-api-error-message
-      (s/constrained s/Num (partial < 0) (tru "Number greater than zero"))
-    (tru "value must be a number greater than zero.")))
+      (s/constrained s/Num (partial < 0) (deferred-tru "Number greater than zero"))
+    (deferred-tru "value must be a number greater than zero.")))
 
 (def KeywordOrString
   "Schema for something that can be either a `Keyword` or a `String`."
-  (s/named (s/cond-pre s/Keyword s/Str) (tru "Keyword or string")))
+  (s/named (s/cond-pre s/Keyword s/Str) (deferred-tru "Keyword or string")))
 
 (def FieldType
   "Schema for a valid Field type (does it derive from `:type/*`)?"
-  (with-api-error-message (s/pred (u/rpartial isa? :type/*) (tru "Valid field type"))
-    (tru "value must be a valid field type.")))
+  (with-api-error-message (s/pred (u/rpartial isa? :type/*) (deferred-tru "Valid field type"))
+    (deferred-tru "value must be a valid field type.")))
 
 (def FieldTypeKeywordOrString
   "Like `FieldType` (e.g. a valid derivative of `:type/*`) but allows either a keyword or a string.
    This is useful especially for validating API input or objects coming out of the DB as it is unlikely
    those values will be encoded as keywords at that point."
-  (with-api-error-message (s/pred #(isa? (keyword %) :type/*) (tru "Valid field type (keyword or string)"))
-    (tru "value must be a valid field type (keyword or string).")))
+  (with-api-error-message (s/pred #(isa? (keyword %) :type/*) (deferred-tru "Valid field type (keyword or string)"))
+    (deferred-tru "value must be a valid field type (keyword or string).")))
 
 (def EntityTypeKeywordOrString
   "Validates entity type derivatives of `:entity/*`. Allows strings or keywords"
-  (with-api-error-message (s/pred #(isa? (keyword %) :entity/*) (tru "Valid entity type (keyword or string)"))
-   (tru "value must be a valid entity type (keyword or string).")))
+  (with-api-error-message (s/pred #(isa? (keyword %) :entity/*) (deferred-tru "Valid entity type (keyword or string)"))
+   (deferred-tru "value must be a valid entity type (keyword or string).")))
 
 (def Map
   "Schema for a valid map."
-  (with-api-error-message (s/pred map? (tru "Valid map"))
-    (tru "value must be a map.")))
+  (with-api-error-message (s/pred map? (deferred-tru "Valid map"))
+    (deferred-tru "value must be a map.")))
 
 (def Email
   "Schema for a valid email string."
-  (with-api-error-message (s/constrained s/Str u/email? (tru "Valid email address"))
-    (tru "value must be a valid email address.")))
+  (with-api-error-message (s/constrained s/Str u/email? (deferred-tru "Valid email address"))
+    (deferred-tru "value must be a valid email address.")))
 
 (def ComplexPassword
   "Schema for a valid password of sufficient complexity."
   (with-api-error-message (s/constrained s/Str password/is-complex?)
-    (tru "Insufficient password strength")))
+    (deferred-tru "Insufficient password strength")))
 
 (def IntString
   "Schema for a string that can be parsed as an integer.
    Something that adheres to this schema is guaranteed to to work with `Integer/parseInt`."
   (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (Integer/parseInt %)))
-    (tru "value must be a valid integer.")))
+    (deferred-tru "value must be a valid integer.")))
 
 (def IntStringGreaterThanZero
   "Schema for a string that can be parsed as an integer, and is greater than zero.
    Something that adheres to this schema is guaranteed to to work with `Integer/parseInt`."
   (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (< 0 (Integer/parseInt %))))
-    (tru "value must be a valid integer greater than zero.")))
+    (deferred-tru "value must be a valid integer greater than zero.")))
 
 (def IntStringGreaterThanOrEqualToZero
   "Schema for a string that can be parsed as an integer, and is greater than or equal to zero.
    Something that adheres to this schema is guaranteed to to work with `Integer/parseInt`."
   (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (<= 0 (Integer/parseInt %))))
-    (tru "value must be a valid integer greater than or equal to zero.")))
+    (deferred-tru "value must be a valid integer greater than or equal to zero.")))
 
 (defn- boolean-string? ^Boolean [s]
   (boolean (when (string? s)
@@ -234,14 +235,14 @@
   "Schema for a string that is a valid representation of a boolean (either `true` or `false`).
    Something that adheres to this schema is guaranteed to to work with `Boolean/parseBoolean`."
   (with-api-error-message (s/constrained s/Str boolean-string?)
-    (tru "value must be a valid boolean string (''true'' or ''false'').")))
+    (deferred-tru "value must be a valid boolean string (''true'' or ''false'').")))
 
 (def JSONString
   "Schema for a string that is valid serialized JSON."
   (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (json/parse-string %)))
-    (tru "value must be a valid JSON string.")))
+    (deferred-tru "value must be a valid JSON string.")))
 
 (def EmbeddingParams
   "Schema for a valid map of embedding params."
   (with-api-error-message (s/maybe {s/Keyword (s/enum "disabled" "enabled" "locked")})
-    (tru "value must be a valid embedding params map.")))
+    (deferred-tru "value must be a valid embedding params map.")))
diff --git a/test/metabase/automagic_dashboards/core_test.clj b/test/metabase/automagic_dashboards/core_test.clj
index 49f38a8f66a15c4fdf74c7f05baa0924fa1135e4..ef683ca1c0cc896496c472acd2400cbfded6912b 100644
--- a/test/metabase/automagic_dashboards/core_test.clj
+++ b/test/metabase/automagic_dashboards/core_test.clj
@@ -22,8 +22,9 @@
              [automagic-dashboards :refer :all]
              [data :as data]
              [util :as tu]]
-            [metabase.util.date :as date]
-            [puppetlabs.i18n.core :as i18n :refer [tru]]
+            [metabase.util
+             [date :as date]
+             [i18n :refer [tru]]]
             [toucan.db :as db]
             [toucan.util.test :as tt]))
 
diff --git a/test/metabase/metabot/command_test.clj b/test/metabase/metabot/command_test.clj
index 495caeac311920b9a75ff8d1abcc5ed9621d5315..0741e5d6386c7934ea2aa259f92a1d59db0d6990 100644
--- a/test/metabase/metabot/command_test.clj
+++ b/test/metabase/metabot/command_test.clj
@@ -106,7 +106,7 @@
 
 ;; If you try to show a Card with an ID that doesn't exist, you should get a Not Found message.
 (expect
-  {:response (list 'Exception. (str (tru "Card {0} not found." Integer/MAX_VALUE)))
+  {:response (list 'Exception. (tru "Card {0} not found." Integer/MAX_VALUE))
    :messages []}
   (command "show" Integer/MAX_VALUE))
 
diff --git a/test/metabase/models/setting_test.clj b/test/metabase/models/setting_test.clj
index 29dbd9c61549310e26ef3a6c7aabd273e9716628..b4c4fcf6b7fbf35aa71e150ba066a4922b10b1b9 100644
--- a/test/metabase/models/setting_test.clj
+++ b/test/metabase/models/setting_test.clj
@@ -7,7 +7,7 @@
             [metabase.util
              [encryption :as encryption]
              [encryption-test :as encryption-test]
-             [i18n :refer [tru]]]
+             [i18n :refer [deferred-tru]]]
             [puppetlabs.i18n.core :as i18n]
             [toucan.db :as db]))
 
@@ -246,7 +246,7 @@
         setting)))
 
 (defsetting ^:private test-i18n-setting
-  (tru "Test setting - with i18n"))
+  (deferred-tru "Test setting - with i18n"))
 
 ;; Validate setting description with i18n string
 (expect
@@ -497,7 +497,7 @@
 ;;; ----------------------------------------------- Sensitive Settings -----------------------------------------------
 
 (defsetting test-sensitive-setting
-  (tru "This is a sample sensitive Setting.")
+  (deferred-tru "This is a sample sensitive Setting.")
   :sensitive? true)
 
 ;; `user-facing-value` should obfuscate sensitive settings
diff --git a/test/metabase/public_settings_test.clj b/test/metabase/public_settings_test.clj
index 8e433e7e23f41161165624f4831380e3d5123811..73fad22a77357df74b3b6f663dd19dd79ceb9e07 100644
--- a/test/metabase/public_settings_test.clj
+++ b/test/metabase/public_settings_test.clj
@@ -5,7 +5,8 @@
             [metabase.public-settings :as public-settings]
             [metabase.test.util :as tu]
             [metabase.test.util.log :as tu.log]
-            [puppetlabs.i18n.core :as i18n :refer [tru]]))
+            [metabase.util.i18n :refer [tru]]
+            [puppetlabs.i18n.core :as i18n]))
 
  ;; double-check that setting the `site-url` setting will automatically strip off trailing slashes
 (expect