diff --git a/newsfragments/4116.fixed.md b/newsfragments/4116.fixed.md
new file mode 100644
index 00000000000..63531aceb39
--- /dev/null
+++ b/newsfragments/4116.fixed.md
@@ -0,0 +1 @@
+Disable `PyUnicode_DATA` on PyPy: Not exposed by PyPy.
diff --git a/pyo3-ffi/src/cpython/unicodeobject.rs b/pyo3-ffi/src/cpython/unicodeobject.rs
index 9ab523a2d7f..feb78cf0c82 100644
--- a/pyo3-ffi/src/cpython/unicodeobject.rs
+++ b/pyo3-ffi/src/cpython/unicodeobject.rs
@@ -449,19 +449,19 @@ pub const PyUnicode_1BYTE_KIND: c_uint = 1;
 pub const PyUnicode_2BYTE_KIND: c_uint = 2;
 pub const PyUnicode_4BYTE_KIND: c_uint = 4;
 
-#[cfg(not(GraalPy))]
+#[cfg(not(any(GraalPy, PyPy)))]
 #[inline]
 pub unsafe fn PyUnicode_1BYTE_DATA(op: *mut PyObject) -> *mut Py_UCS1 {
     PyUnicode_DATA(op) as *mut Py_UCS1
 }
 
-#[cfg(not(GraalPy))]
+#[cfg(not(any(GraalPy, PyPy)))]
 #[inline]
 pub unsafe fn PyUnicode_2BYTE_DATA(op: *mut PyObject) -> *mut Py_UCS2 {
     PyUnicode_DATA(op) as *mut Py_UCS2
 }
 
-#[cfg(not(GraalPy))]
+#[cfg(not(any(GraalPy, PyPy)))]
 #[inline]
 pub unsafe fn PyUnicode_4BYTE_DATA(op: *mut PyObject) -> *mut Py_UCS4 {
     PyUnicode_DATA(op) as *mut Py_UCS4
@@ -487,7 +487,7 @@ pub unsafe fn _PyUnicode_COMPACT_DATA(op: *mut PyObject) -> *mut c_void {
     }
 }
 
-#[cfg(not(GraalPy))]
+#[cfg(not(any(GraalPy, PyPy)))]
 #[inline]
 pub unsafe fn _PyUnicode_NONCOMPACT_DATA(op: *mut PyObject) -> *mut c_void {
     debug_assert!(!(*(op as *mut PyUnicodeObject)).data.any.is_null());
@@ -495,7 +495,7 @@ pub unsafe fn _PyUnicode_NONCOMPACT_DATA(op: *mut PyObject) -> *mut c_void {
     (*(op as *mut PyUnicodeObject)).data.any
 }
 
-#[cfg(not(GraalPy))]
+#[cfg(not(any(GraalPy, PyPy)))]
 #[inline]
 pub unsafe fn PyUnicode_DATA(op: *mut PyObject) -> *mut c_void {
     debug_assert!(crate::PyUnicode_Check(op) != 0);
diff --git a/src/ffi/tests.rs b/src/ffi/tests.rs
index 3532172c933..5aee1618472 100644
--- a/src/ffi/tests.rs
+++ b/src/ffi/tests.rs
@@ -2,11 +2,14 @@ use crate::ffi::*;
 use crate::types::any::PyAnyMethods;
 use crate::Python;
 
+#[cfg(all(PyPy, feature = "macros"))]
+use crate::types::PyString;
+
+#[cfg(not(any(Py_LIMITED_API, PyPy)))]
+use crate::types::PyString;
+
 #[cfg(not(Py_LIMITED_API))]
-use crate::{
-    types::{PyDict, PyString},
-    Bound, IntoPy, Py, PyAny,
-};
+use crate::{types::PyDict, Bound, IntoPy, Py, PyAny};
 #[cfg(not(any(Py_3_12, Py_LIMITED_API)))]
 use libc::wchar_t;
 
@@ -160,7 +163,7 @@ fn ascii_object_bitfield() {
 }
 
 #[test]
-#[cfg(not(Py_LIMITED_API))]
+#[cfg(not(any(Py_LIMITED_API, PyPy)))]
 #[cfg_attr(Py_3_10, allow(deprecated))]
 fn ascii() {
     Python::with_gil(|py| {
@@ -202,7 +205,7 @@ fn ascii() {
 }
 
 #[test]
-#[cfg(not(Py_LIMITED_API))]
+#[cfg(not(any(Py_LIMITED_API, PyPy)))]
 #[cfg_attr(Py_3_10, allow(deprecated))]
 fn ucs4() {
     Python::with_gil(|py| {
diff --git a/src/types/string.rs b/src/types/string.rs
index 4aa73341ae9..09c5903547c 100644
--- a/src/types/string.rs
+++ b/src/types/string.rs
@@ -264,7 +264,7 @@ impl PyString {
     ///
     /// By using this API, you accept responsibility for testing that PyStringData behaves as
     /// expected on the targets where you plan to distribute your software.
-    #[cfg(not(any(Py_LIMITED_API, GraalPy)))]
+    #[cfg(not(any(Py_LIMITED_API, GraalPy, PyPy)))]
     pub unsafe fn data(&self) -> PyResult<PyStringData<'_>> {
         self.as_borrowed().data()
     }
@@ -313,7 +313,7 @@ pub trait PyStringMethods<'py>: crate::sealed::Sealed {
     ///
     /// By using this API, you accept responsibility for testing that PyStringData behaves as
     /// expected on the targets where you plan to distribute your software.
-    #[cfg(not(any(Py_LIMITED_API, GraalPy)))]
+    #[cfg(not(any(Py_LIMITED_API, GraalPy, PyPy)))]
     unsafe fn data(&self) -> PyResult<PyStringData<'_>>;
 }
 
@@ -339,7 +339,7 @@ impl<'py> PyStringMethods<'py> for Bound<'py, PyString> {
         }
     }
 
-    #[cfg(not(any(Py_LIMITED_API, GraalPy)))]
+    #[cfg(not(any(Py_LIMITED_API, GraalPy, PyPy)))]
     unsafe fn data(&self) -> PyResult<PyStringData<'_>> {
         self.as_borrowed().data()
     }
@@ -402,7 +402,7 @@ impl<'a> Borrowed<'a, '_, PyString> {
         Cow::Owned(String::from_utf8_lossy(bytes.as_bytes()).into_owned())
     }
 
-    #[cfg(not(any(Py_LIMITED_API, GraalPy)))]
+    #[cfg(not(any(Py_LIMITED_API, GraalPy, PyPy)))]
     unsafe fn data(self) -> PyResult<PyStringData<'a>> {
         let ptr = self.as_ptr();
 
@@ -584,7 +584,7 @@ mod tests {
     }
 
     #[test]
-    #[cfg(not(Py_LIMITED_API))]
+    #[cfg(not(any(Py_LIMITED_API, PyPy)))]
     fn test_string_data_ucs1() {
         Python::with_gil(|py| {
             let s = PyString::new_bound(py, "hello, world");
@@ -597,7 +597,7 @@ mod tests {
     }
 
     #[test]
-    #[cfg(not(Py_LIMITED_API))]
+    #[cfg(not(any(Py_LIMITED_API, PyPy)))]
     fn test_string_data_ucs1_invalid() {
         Python::with_gil(|py| {
             // 0xfe is not allowed in UTF-8.
@@ -625,7 +625,7 @@ mod tests {
     }
 
     #[test]
-    #[cfg(not(Py_LIMITED_API))]
+    #[cfg(not(any(Py_LIMITED_API, PyPy)))]
     fn test_string_data_ucs2() {
         Python::with_gil(|py| {
             let s = py.eval_bound("'foo\\ud800'", None, None).unwrap();
@@ -641,7 +641,7 @@ mod tests {
     }
 
     #[test]
-    #[cfg(all(not(Py_LIMITED_API), target_endian = "little"))]
+    #[cfg(all(not(any(Py_LIMITED_API, PyPy)), target_endian = "little"))]
     fn test_string_data_ucs2_invalid() {
         Python::with_gil(|py| {
             // U+FF22 (valid) & U+d800 (never valid)
@@ -669,7 +669,7 @@ mod tests {
     }
 
     #[test]
-    #[cfg(not(Py_LIMITED_API))]
+    #[cfg(not(any(Py_LIMITED_API, PyPy)))]
     fn test_string_data_ucs4() {
         Python::with_gil(|py| {
             let s = "哈哈🐈";
@@ -682,7 +682,7 @@ mod tests {
     }
 
     #[test]
-    #[cfg(all(not(Py_LIMITED_API), target_endian = "little"))]
+    #[cfg(all(not(any(Py_LIMITED_API, PyPy)), target_endian = "little"))]
     fn test_string_data_ucs4_invalid() {
         Python::with_gil(|py| {
             // U+20000 (valid) & U+d800 (never valid)