diff --git a/cli/tsc/99_main_compiler.js b/cli/tsc/99_main_compiler.js
index f944b21b8d25fd..b4626374d81648 100644
--- a/cli/tsc/99_main_compiler.js
+++ b/cli/tsc/99_main_compiler.js
@@ -34,15 +34,16 @@ delete Object.prototype.__proto__;
     }
   }
 
+  function printStderr(msg) {
+    core.print(msg, true);
+  }
+
   function debug(...args) {
     if (logDebug) {
       const stringifiedArgs = args.map((arg) =>
         typeof arg === "string" ? arg : JSON.stringify(arg)
       ).join(" ");
-      // adding a non-zero integer value to the end of the debug string causes
-      // the message to be printed to stderr instead of stdout, which is better
-      // aligned to the behaviour of debug messages
-      core.print(`DEBUG ${logSource} - ${stringifiedArgs}\n`, 1);
+      printStderr(`DEBUG ${logSource} - ${stringifiedArgs}\n`);
     }
   }
 
@@ -52,7 +53,7 @@ delete Object.prototype.__proto__;
         ? String(arg)
         : JSON.stringify(arg)
     ).join(" ");
-    core.print(`ERROR ${logSource} = ${stringifiedArgs}\n`, 1);
+    printStderr(`ERROR ${logSource} = ${stringifiedArgs}\n`);
   }
 
   class AssertionError extends Error {
diff --git a/cli/tsc/compiler.d.ts b/cli/tsc/compiler.d.ts
index 13e6564b44dbb4..e5ce12cd323f5d 100644
--- a/cli/tsc/compiler.d.ts
+++ b/cli/tsc/compiler.d.ts
@@ -36,7 +36,7 @@ declare global {
     // deno-lint-ignore no-explicit-any
     opSync<T>(name: string, params: T): any;
     ops(): void;
-    print(msg: string, code?: number): void;
+    print(msg: string, stderr: bool): void;
     registerErrorClass(
       name: string,
       Ctor: typeof Error,
diff --git a/core/bindings.rs b/core/bindings.rs
index ea45daff4c44fb..8a32bc5da462fc 100644
--- a/core/bindings.rs
+++ b/core/bindings.rs
@@ -13,7 +13,6 @@ use serde::Serialize;
 use serde_v8::to_v8;
 use std::convert::TryFrom;
 use std::convert::TryInto;
-use std::io::{stdout, Write};
 use std::option::Option;
 use url::Url;
 use v8::MapFnTo;
@@ -21,9 +20,6 @@ use v8::MapFnTo;
 lazy_static::lazy_static! {
   pub static ref EXTERNAL_REFERENCES: v8::ExternalReferences =
     v8::ExternalReferences::new(&[
-      v8::ExternalReference {
-        function: print.map_fn_to()
-      },
       v8::ExternalReference {
         function: opcall.map_fn_to()
       },
@@ -117,7 +113,6 @@ pub fn initialize_context<'s>(
   deno_val.set(scope, core_key.into(), core_val.into());
 
   // Bind functions to Deno.core.*
-  set_func(scope, core_val, "print", print);
   set_func(scope, core_val, "opcall", opcall);
   set_func(
     scope,
@@ -268,41 +263,6 @@ pub extern "C" fn promise_reject_callback(message: v8::PromiseRejectMessage) {
   };
 }
 
-fn print(
-  scope: &mut v8::HandleScope,
-  args: v8::FunctionCallbackArguments,
-  _rv: v8::ReturnValue,
-) {
-  let arg_len = args.length();
-  if !(0..=2).contains(&arg_len) {
-    return throw_type_error(scope, "Expected a maximum of 2 arguments.");
-  }
-
-  let obj = args.get(0);
-  let is_err_arg = args.get(1);
-
-  let mut is_err = false;
-  if arg_len == 2 {
-    let int_val = match is_err_arg.integer_value(scope) {
-      Some(v) => v,
-      None => return throw_type_error(scope, "Invalid argument. Argument 2 should indicate whether or not to print to stderr."),
-    };
-    is_err = int_val != 0;
-  };
-  let tc_scope = &mut v8::TryCatch::new(scope);
-  let str_ = match obj.to_string(tc_scope) {
-    Some(s) => s,
-    None => v8::String::new(tc_scope, "").unwrap(),
-  };
-  if is_err {
-    eprint!("{}", str_.to_rust_string_lossy(tc_scope));
-    stdout().flush().unwrap();
-  } else {
-    print!("{}", str_.to_rust_string_lossy(tc_scope));
-    stdout().flush().unwrap();
-  }
-}
-
 fn opcall<'s>(
   scope: &mut v8::HandleScope<'s>,
   args: v8::FunctionCallbackArguments,
diff --git a/core/core.js b/core/core.js
index e5998fbc84eeb5..729ca4faa40a1e 100644
--- a/core/core.js
+++ b/core/core.js
@@ -124,6 +124,10 @@
     opSync("op_close", rid);
   }
 
+  function print(str, isErr = false) {
+    opSync("op_print", [str, isErr]);
+  }
+
   // Provide bootstrap namespace
   window.__bootstrap = {};
   // Extra Deno.core.* exports
@@ -132,6 +136,7 @@
     opSync,
     ops,
     close,
+    print,
     resources,
     registerErrorClass,
     handleAsyncMsgFromRust,
diff --git a/core/lib.rs b/core/lib.rs
index adfd9a6a22c437..4e7345c4028625 100644
--- a/core/lib.rs
+++ b/core/lib.rs
@@ -65,6 +65,7 @@ pub use crate::ops::OpState;
 pub use crate::ops::OpTable;
 pub use crate::ops::PromiseId;
 pub use crate::ops_builtin::op_close;
+pub use crate::ops_builtin::op_print;
 pub use crate::ops_builtin::op_resources;
 pub use crate::ops_json::op_async;
 pub use crate::ops_json::op_sync;
diff --git a/core/ops_builtin.rs b/core/ops_builtin.rs
index 96aca5c53350da..546d9cf32d1ac3 100644
--- a/core/ops_builtin.rs
+++ b/core/ops_builtin.rs
@@ -7,6 +7,7 @@ use crate::resources::ResourceId;
 use crate::Extension;
 use crate::OpState;
 use crate::ZeroCopyBuf;
+use std::io::{stdout, Write};
 
 pub(crate) fn init_builtins() -> Extension {
   Extension::builder()
@@ -17,6 +18,7 @@ pub(crate) fn init_builtins() -> Extension {
     ))
     .ops(vec![
       ("op_close", op_sync(op_close)),
+      ("op_print", op_sync(op_print)),
       ("op_resources", op_sync(op_resources)),
     ])
     .build()
@@ -52,3 +54,20 @@ pub fn op_close(
 
   Ok(())
 }
+
+/// Builtin utility to print to stdout/stderr
+pub fn op_print(
+  _state: &mut OpState,
+  args: (String, bool),
+  _zero_copy: Option<ZeroCopyBuf>,
+) -> Result<(), AnyError> {
+  let (msg, is_err) = args;
+  if is_err {
+    eprint!("{}", msg);
+    stdout().flush().unwrap();
+  } else {
+    print!("{}", msg);
+    stdout().flush().unwrap();
+  }
+  Ok(())
+}