diff --git a/pyproject.toml b/pyproject.toml
index d09c62a10..06a8ec9fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,7 +12,7 @@ authors = [
     { name = 'David Montague', email = 'david@pydantic.dev' },
     { name = 'David Hewitt', email = 'mail@davidhewitt.dev' },
     { name = 'Sydney Runkle', email = 'sydneymarierunkle@gmail.com' },
-    { name = 'Victorien Plot', email='contact@vctrn.dev' },
+    { name = 'Victorien Plot', email = 'contact@vctrn.dev' },
 ]
 classifiers = [
     'Development Status :: 3 - Alpha',
@@ -149,6 +149,9 @@ require_change_file = false
 [tool.pyright]
 include = ['python/pydantic_core', 'tests/test_typing.py']
 reportUnnecessaryTypeIgnoreComment = true
+executionEnvironments = [
+    { root = "tests", reportPrivateImportUsage = false, reportMissingParameterType = false, reportAny = false },
+]
 
 [tool.inline-snapshot.shortcuts]
 fix = ["create", "fix"]
diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi
index 398020bb1..2cd297da9 100644
--- a/python/pydantic_core/_pydantic_core.pyi
+++ b/python/pydantic_core/_pydantic_core.pyi
@@ -522,8 +522,15 @@ class Url(SupportsAllComparisons):
     by Mozilla.
     """
 
-    def __init__(self, url: str) -> None: ...
-    def __new__(cls, url: str) -> Self: ...
+    def __init__(self, url: str, *, add_trailing_slash: bool = True) -> None:
+        """Initialize a new URL object.
+
+        Args:
+            url: The URL string to parse.
+            add_trailing_slash: Whether to add an extra trailing slash to some URLs, defaults to `True` for
+                backward compatibility, default will change to `False` in v3 version.
+        """
+    def __new__(cls, url: str, *, add_trailing_slash: bool = True) -> Self: ...
     @property
     def scheme(self) -> str: ...
     @property
@@ -568,8 +575,15 @@ class MultiHostUrl(SupportsAllComparisons):
     by Mozilla.
     """
 
-    def __init__(self, url: str) -> None: ...
-    def __new__(cls, url: str) -> Self: ...
+    def __init__(self, url: str, *, add_trailing_slash: bool = True) -> None:
+        """Initialize a new MultiHostUrl object.
+
+        Args:
+            url: The URL string to parse.
+            add_trailing_slash: Whether to add an extra trailing slash to some URLs, defaults to `True` for
+                backward compatibility, default will change to `False` in v3 version.
+        """
+    def __new__(cls, url: str, *, add_trailing_slash: bool = True) -> Self: ...
     @property
     def scheme(self) -> str: ...
     @property
diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py
index f03daac36..cc0a56246 100644
--- a/python/pydantic_core/core_schema.py
+++ b/python/pydantic_core/core_schema.py
@@ -75,6 +75,8 @@ class CoreConfig(TypedDict, total=False):
         validate_by_alias: Whether to use the field's alias when validating against the provided input data. Default is `True`.
         validate_by_name: Whether to use the field's name when validating against the provided input data. Default is `False`. Replacement for `populate_by_name`.
         serialize_by_alias: Whether to serialize by alias. Default is `False`, expected to change to `True` in V3.
+        url_add_trailing_slash: Whether to add an extra trailing slash to some URLs, defaults to `True` for
+            backward compatibility, default will change to `False` in v3 version.
     """
 
     title: str
@@ -114,6 +116,7 @@ class CoreConfig(TypedDict, total=False):
     validate_by_alias: bool  # default: True
     validate_by_name: bool  # default: False
     serialize_by_alias: bool  # default: False
+    url_add_trailing_slash: bool  # default: True
 
 
 IncExCall: TypeAlias = 'set[int | str] | dict[int | str, IncExCall] | None'
@@ -3824,6 +3827,7 @@ class UrlSchema(TypedDict, total=False):
     default_host: str
     default_port: int
     default_path: str
+    add_trailing_slash: bool
     strict: bool
     ref: str
     metadata: dict[str, Any]
@@ -3838,6 +3842,7 @@ def url_schema(
     default_host: str | None = None,
     default_port: int | None = None,
     default_path: str | None = None,
+    add_trailing_slash: bool | None = None,
     strict: bool | None = None,
     ref: str | None = None,
     metadata: dict[str, Any] | None = None,
@@ -3862,6 +3867,8 @@ def url_schema(
         default_host: The default host to use if the URL does not have a host
         default_port: The default port to use if the URL does not have a port
         default_path: The default path to use if the URL does not have a path
+        add_trailing_slash: Whether to add an extra trailing slash to some URLs, defaults to `True` for
+            backward compatibility, default will change to `False` in v3 version.
         strict: Whether to use strict URL parsing
         ref: optional unique identifier of the schema, used to reference the schema in other places
         metadata: Any other information you want to include with the schema, not used by pydantic-core
@@ -3879,6 +3886,7 @@ def url_schema(
         ref=ref,
         metadata=metadata,
         serialization=serialization,
+        add_trailing_slash=add_trailing_slash,
     )
 
 
@@ -3890,6 +3898,7 @@ class MultiHostUrlSchema(TypedDict, total=False):
     default_host: str
     default_port: int
     default_path: str
+    add_trailing_slash: bool
     strict: bool
     ref: str
     metadata: dict[str, Any]
@@ -3904,6 +3913,7 @@ def multi_host_url_schema(
     default_host: str | None = None,
     default_port: int | None = None,
     default_path: str | None = None,
+    add_trailing_slash: bool | None = None,
     strict: bool | None = None,
     ref: str | None = None,
     metadata: dict[str, Any] | None = None,
@@ -3928,6 +3938,8 @@ def multi_host_url_schema(
         default_host: The default host to use if the URL does not have a host
         default_port: The default port to use if the URL does not have a port
         default_path: The default path to use if the URL does not have a path
+        add_trailing_slash: Whether to add an extra trailing slash to some URLs, defaults to `True` for
+            backward compatibility, default will change to `False` in v3 version.
         strict: Whether to use strict URL parsing
         ref: optional unique identifier of the schema, used to reference the schema in other places
         metadata: Any other information you want to include with the schema, not used by pydantic-core
@@ -3941,6 +3953,7 @@ def multi_host_url_schema(
         default_host=default_host,
         default_port=default_port,
         default_path=default_path,
+        add_trailing_slash=add_trailing_slash,
         strict=strict,
         ref=ref,
         metadata=metadata,
diff --git a/src/url.rs b/src/url.rs
index da63be5af..22bb8c443 100644
--- a/src/url.rs
+++ b/src/url.rs
@@ -14,38 +14,44 @@ use url::Url;
 use crate::tools::SchemaDict;
 use crate::SchemaValidator;
 
-static SCHEMA_DEFINITION_URL: GILOnceCell<SchemaValidator> = GILOnceCell::new();
-
 #[pyclass(name = "Url", module = "pydantic_core._pydantic_core", subclass, frozen)]
 #[derive(Clone, Hash)]
 #[cfg_attr(debug_assertions, derive(Debug))]
 pub struct PyUrl {
     lib_url: Url,
+    remove_trailing_slash: bool,
 }
 
 impl PyUrl {
-    pub fn new(lib_url: Url) -> Self {
-        Self { lib_url }
+    pub fn new(lib_url: Url, remove_trailing_slash: bool) -> Self {
+        Self {
+            lib_url,
+            remove_trailing_slash,
+        }
     }
 
     pub fn url(&self) -> &Url {
         &self.lib_url
     }
+
+    pub fn mut_url(&mut self) -> &mut Url {
+        &mut self.lib_url
+    }
 }
 
-fn build_schema_validator(py: Python, schema_type: &str) -> SchemaValidator {
-    let schema = PyDict::new(py);
-    schema.set_item("type", schema_type).unwrap();
-    SchemaValidator::py_new(py, &schema, None).unwrap()
+impl From<PyUrl> for Url {
+    fn from(value: PyUrl) -> Url {
+        value.lib_url
+    }
 }
 
 #[pymethods]
 impl PyUrl {
     #[new]
-    pub fn py_new(py: Python, url: &Bound<'_, PyAny>) -> PyResult<Self> {
-        let schema_obj = SCHEMA_DEFINITION_URL
-            .get_or_init(py, || build_schema_validator(py, "url"))
-            .validate_python(py, url, None, None, None, None, false.into(), None, None)?;
+    #[pyo3(signature = (url, *, add_trailing_slash=true))]
+    pub fn py_new(py: Python, url: &Bound<'_, PyAny>, add_trailing_slash: bool) -> PyResult<Self> {
+        let schema_validator = get_schema_validator(py, false, add_trailing_slash)?;
+        let schema_obj = schema_validator.validate_python(py, url, None, None, None, None, false.into(), None, None)?;
         schema_obj.extract(py)
     }
 
@@ -114,11 +120,15 @@ impl PyUrl {
 
     // string representation of the URL, with punycode decoded when appropriate
     pub fn unicode_string(&self) -> String {
-        unicode_url(&self.lib_url)
+        unicode_url(&self.lib_url, self.remove_trailing_slash)
     }
 
     pub fn __str__(&self) -> &str {
-        self.lib_url.as_str()
+        let mut s = self.lib_url.as_str();
+        if self.remove_trailing_slash && s.ends_with('/') {
+            s = &s[..s.len() - 1];
+        }
+        s
     }
 
     pub fn __repr__(&self) -> String {
@@ -201,11 +211,8 @@ pub struct PyMultiHostUrl {
 }
 
 impl PyMultiHostUrl {
-    pub fn new(ref_url: Url, extra_urls: Option<Vec<Url>>) -> Self {
-        Self {
-            ref_url: PyUrl::new(ref_url),
-            extra_urls,
-        }
+    pub fn new(ref_url: PyUrl, extra_urls: Option<Vec<Url>>) -> Self {
+        Self { ref_url, extra_urls }
     }
 
     pub fn lib_url(&self) -> &Url {
@@ -217,15 +224,13 @@ impl PyMultiHostUrl {
     }
 }
 
-static SCHEMA_DEFINITION_MULTI_HOST_URL: GILOnceCell<SchemaValidator> = GILOnceCell::new();
-
 #[pymethods]
 impl PyMultiHostUrl {
     #[new]
-    pub fn py_new(py: Python, url: &Bound<'_, PyAny>) -> PyResult<Self> {
-        let schema_obj = SCHEMA_DEFINITION_MULTI_HOST_URL
-            .get_or_init(py, || build_schema_validator(py, "multi-host-url"))
-            .validate_python(py, url, None, None, None, None, false.into(), None, None)?;
+    #[pyo3(signature = (url, *, add_trailing_slash=true))]
+    pub fn py_new(py: Python, url: &Bound<'_, PyAny>, add_trailing_slash: bool) -> PyResult<Self> {
+        let schema_validator = get_schema_validator(py, true, add_trailing_slash)?;
+        let schema_obj = schema_validator.validate_python(py, url, None, None, None, None, false.into(), None, None)?;
         schema_obj.extract(py)
     }
 
@@ -279,13 +284,12 @@ impl PyMultiHostUrl {
 
             // special urls will have had a trailing slash added, non-special urls will not
             // hence we need to remove the last char if the schema is special
-            #[allow(clippy::bool_to_int_with_if)]
-            let sub = if schema_is_special(schema) { 1 } else { 0 };
+            let sub: usize = schema_is_special(schema).into();
 
             let hosts = extra_urls
                 .iter()
                 .map(|url| {
-                    let str = unicode_url(url);
+                    let str = unicode_url(url, false);
                     str[host_offset..str.len() - sub].to_string()
                 })
                 .collect::<Vec<String>>()
@@ -302,13 +306,12 @@ impl PyMultiHostUrl {
             let schema = self.ref_url.lib_url.scheme();
             let host_offset = schema.len() + 3;
 
-            let mut full_url = self.ref_url.lib_url.to_string();
+            let mut full_url = self.ref_url.__str__().to_string();
             full_url.insert(host_offset, ',');
 
             // special urls will have had a trailing slash added, non-special urls will not
             // hence we need to remove the last char if the schema is special
-            #[allow(clippy::bool_to_int_with_if)]
-            let sub = if schema_is_special(schema) { 1 } else { 0 };
+            let sub: usize = schema_is_special(schema).into();
 
             let hosts = extra_urls
                 .iter()
@@ -316,7 +319,7 @@ impl PyMultiHostUrl {
                     let str = url.as_str();
                     &str[host_offset..str.len() - sub]
                 })
-                .collect::<Vec<&str>>()
+                .collect::<Vec<_>>()
                 .join(",");
             full_url.insert_str(host_offset, &hosts);
             full_url
@@ -477,10 +480,10 @@ fn host_to_dict<'a>(py: Python<'a>, lib_url: &Url) -> PyResult<Bound<'a, PyDict>
     Ok(dict)
 }
 
-fn unicode_url(lib_url: &Url) -> String {
+fn unicode_url(lib_url: &Url, remove_trailing_slash: bool) -> String {
     let mut s = lib_url.to_string();
 
-    match lib_url.host() {
+    s = match lib_url.host() {
         Some(url::Host::Domain(domain)) if is_punnycode_domain(lib_url, domain) => {
             if let Some(decoded) = decode_punycode(domain) {
                 // replace the range containing the punycode domain with the decoded domain
@@ -490,7 +493,11 @@ fn unicode_url(lib_url: &Url) -> String {
             s
         }
         _ => s,
+    };
+    if remove_trailing_slash && s.ends_with('/') {
+        s.pop();
     }
+    s
 }
 
 fn decode_punycode(domain: &str) -> Option<String> {
@@ -517,3 +524,29 @@ fn is_punnycode_domain(lib_url: &Url, domain: &str) -> bool {
 pub fn schema_is_special(schema: &str) -> bool {
     matches!(schema, "http" | "https" | "ws" | "wss" | "ftp" | "file")
 }
+
+static SCHEMA_URL_SINGLE_TRUE: GILOnceCell<SchemaValidator> = GILOnceCell::new();
+static SCHEMA_URL_SINGLE_FALSE: GILOnceCell<SchemaValidator> = GILOnceCell::new();
+static SCHEMA_URL_MULTI_TRUE: GILOnceCell<SchemaValidator> = GILOnceCell::new();
+static SCHEMA_URL_MULTI_FALSE: GILOnceCell<SchemaValidator> = GILOnceCell::new();
+
+macro_rules! make_schema_val {
+    ($py:ident, $schema_type:literal, $add_trailing_slash:literal) => {{
+        let schema = PyDict::new($py);
+        schema.set_item(intern!($py, "type"), intern!($py, $schema_type))?;
+        // add_trailing_slash defaults to true, so only set it if false
+        if !$add_trailing_slash {
+            schema.set_item(intern!($py, "add_trailing_slash"), false)?;
+        }
+        SchemaValidator::py_new($py, &schema, None)
+    }};
+}
+
+fn get_schema_validator(py: Python<'_>, multi_host: bool, add_trailing_slash: bool) -> PyResult<&SchemaValidator> {
+    match (multi_host, add_trailing_slash) {
+        (false, true) => SCHEMA_URL_SINGLE_TRUE.get_or_try_init(py, || make_schema_val!(py, "url", true)),
+        (false, false) => SCHEMA_URL_SINGLE_FALSE.get_or_try_init(py, || make_schema_val!(py, "url", false)),
+        (true, true) => SCHEMA_URL_MULTI_TRUE.get_or_try_init(py, || make_schema_val!(py, "multi-host-url", true)),
+        (true, false) => SCHEMA_URL_MULTI_FALSE.get_or_try_init(py, || make_schema_val!(py, "multi-host-url", false)),
+    }
+}
diff --git a/src/validators/url.rs b/src/validators/url.rs
index d220d60ed..a50f0d50f 100644
--- a/src/validators/url.rs
+++ b/src/validators/url.rs
@@ -10,7 +10,7 @@ use ahash::AHashSet;
 use pyo3::IntoPyObjectExt;
 use url::{ParseError, SyntaxViolation, Url};
 
-use crate::build_tools::{is_strict, py_schema_err};
+use crate::build_tools::{is_strict, py_schema_err, schema_or_config};
 use crate::errors::ToErrorValue;
 use crate::errors::{ErrorType, ErrorTypeDefaults, ValError, ValResult};
 use crate::input::downcast_python_input;
@@ -34,6 +34,17 @@ pub struct UrlValidator {
     default_port: Option<u16>,
     default_path: Option<String>,
     name: String,
+    add_trailing_slash: bool,
+}
+
+fn get_add_trailing_slash(schema: &Bound<'_, PyDict>, config: Option<&Bound<'_, PyDict>>) -> PyResult<bool> {
+    schema_or_config(
+        schema,
+        config,
+        intern!(schema.py(), "add_trailing_slash"),
+        intern!(schema.py(), "url_add_trailing_slash"),
+    )
+    .map(|v| v.unwrap_or(true))
 }
 
 impl BuildValidator for UrlValidator {
@@ -55,6 +66,7 @@ impl BuildValidator for UrlValidator {
             default_path: schema.get_as(intern!(schema.py(), "default_path"))?,
             allowed_schemes,
             name,
+            add_trailing_slash: get_add_trailing_slash(schema, config)?,
         }
         .into())
     }
@@ -69,7 +81,7 @@ impl Validator for UrlValidator {
         input: &(impl Input<'py> + ?Sized),
         state: &mut ValidationState<'_, 'py>,
     ) -> ValResult<PyObject> {
-        let mut either_url = self.get_url(input, state.strict_or(self.strict))?;
+        let mut either_url = self.get_url(input, state.strict_or(self.strict), self.add_trailing_slash)?;
 
         if let Some((ref allowed_schemes, ref expected_schemes_repr)) = self.allowed_schemes {
             if !allowed_schemes.contains(either_url.url().scheme()) {
@@ -106,7 +118,12 @@ impl Validator for UrlValidator {
 }
 
 impl UrlValidator {
-    fn get_url<'py>(&self, input: &(impl Input<'py> + ?Sized), strict: bool) -> ValResult<EitherUrl<'py>> {
+    fn get_url<'py>(
+        &self,
+        input: &(impl Input<'py> + ?Sized),
+        strict: bool,
+        add_trailing_slash: bool,
+    ) -> ValResult<EitherUrl<'py>> {
         match input.validate_str(strict, false) {
             Ok(val_match) => {
                 let either_str = val_match.into_inner();
@@ -115,19 +132,19 @@ impl UrlValidator {
 
                 self.check_length(input, url_str)?;
 
-                parse_url(url_str, input, strict).map(EitherUrl::Rust)
+                parse_url(url_str, input, strict, add_trailing_slash).map(EitherUrl::Owned)
             }
             Err(_) => {
                 // we don't need to worry about whether the url was parsed in strict mode before,
                 // even if it was, any syntax errors would have been fixed by the first validation
                 if let Some(py_url) = downcast_python_input::<PyUrl>(input) {
                     self.check_length(input, py_url.get().url().as_str())?;
-                    Ok(EitherUrl::Py(py_url.clone()))
+                    Ok(EitherUrl::Bound(py_url.clone()))
                 } else if let Some(multi_host_url) = downcast_python_input::<PyMultiHostUrl>(input) {
                     let url_str = multi_host_url.get().__str__();
                     self.check_length(input, &url_str)?;
 
-                    parse_url(&url_str, input, strict).map(EitherUrl::Rust)
+                    parse_url(&url_str, input, strict, add_trailing_slash).map(EitherUrl::Owned)
                 } else {
                     Err(ValError::new(ErrorTypeDefaults::UrlType, input))
                 }
@@ -151,9 +168,10 @@ impl UrlValidator {
     }
 }
 
+// TODO do we still need this?
 enum EitherUrl<'py> {
-    Py(Bound<'py, PyUrl>),
-    Rust(Url),
+    Bound(Bound<'py, PyUrl>),
+    Owned(PyUrl),
 }
 
 impl<'py> IntoPyObject<'py> for EitherUrl<'py> {
@@ -163,8 +181,8 @@ impl<'py> IntoPyObject<'py> for EitherUrl<'py> {
 
     fn into_pyobject(self, py: Python<'py>) -> PyResult<Self::Output> {
         match self {
-            EitherUrl::Py(py_url) => Ok(py_url),
-            EitherUrl::Rust(rust_url) => Bound::new(py, PyUrl::new(rust_url)),
+            EitherUrl::Bound(py_url) => Ok(py_url),
+            EitherUrl::Owned(py_url) => Bound::new(py, py_url),
         }
     }
 }
@@ -172,18 +190,18 @@ impl<'py> IntoPyObject<'py> for EitherUrl<'py> {
 impl CopyFromPyUrl for EitherUrl<'_> {
     fn url(&self) -> &Url {
         match self {
-            EitherUrl::Py(py_url) => py_url.get().url(),
-            EitherUrl::Rust(rust_url) => rust_url,
+            EitherUrl::Bound(py_url) => py_url.get().url(),
+            EitherUrl::Owned(rust_url) => rust_url.url(),
         }
     }
 
     fn url_mut(&mut self) -> &mut Url {
-        if let EitherUrl::Py(py_url) = self {
-            *self = EitherUrl::Rust(py_url.get().url().clone());
+        if let EitherUrl::Bound(py_url) = self {
+            *self = EitherUrl::Owned(py_url.get().clone());
         }
         match self {
-            EitherUrl::Py(_) => unreachable!(),
-            EitherUrl::Rust(rust_url) => rust_url,
+            EitherUrl::Bound(_) => unreachable!(),
+            EitherUrl::Owned(ref mut rust_url) => rust_url.mut_url(),
         }
     }
 }
@@ -198,6 +216,7 @@ pub struct MultiHostUrlValidator {
     default_port: Option<u16>,
     default_path: Option<String>,
     name: String,
+    add_trailing_slash: bool,
 }
 
 impl BuildValidator for MultiHostUrlValidator {
@@ -225,6 +244,7 @@ impl BuildValidator for MultiHostUrlValidator {
             default_port: schema.get_as(intern!(schema.py(), "default_port"))?,
             default_path: schema.get_as(intern!(schema.py(), "default_path"))?,
             name,
+            add_trailing_slash: get_add_trailing_slash(schema, config)?,
         }
         .into())
     }
@@ -239,7 +259,7 @@ impl Validator for MultiHostUrlValidator {
         input: &(impl Input<'py> + ?Sized),
         state: &mut ValidationState<'_, 'py>,
     ) -> ValResult<PyObject> {
-        let mut multi_url = self.get_url(input, state.strict_or(self.strict))?;
+        let mut multi_url = self.get_url(input, state.strict_or(self.strict), self.add_trailing_slash)?;
 
         if let Some((ref allowed_schemes, ref expected_schemes_repr)) = self.allowed_schemes {
             if !allowed_schemes.contains(multi_url.url().scheme()) {
@@ -275,7 +295,12 @@ impl Validator for MultiHostUrlValidator {
 }
 
 impl MultiHostUrlValidator {
-    fn get_url<'py>(&self, input: &(impl Input<'py> + ?Sized), strict: bool) -> ValResult<EitherMultiHostUrl<'py>> {
+    fn get_url<'py>(
+        &self,
+        input: &(impl Input<'py> + ?Sized),
+        strict: bool,
+        add_trailing_slash: bool,
+    ) -> ValResult<EitherMultiHostUrl<'py>> {
         match input.validate_str(strict, false) {
             Ok(val_match) => {
                 let either_str = val_match.into_inner();
@@ -284,7 +309,7 @@ impl MultiHostUrlValidator {
 
                 self.check_length(input, || url_str.len())?;
 
-                parse_multihost_url(url_str, input, strict).map(EitherMultiHostUrl::Rust)
+                parse_multihost_url(url_str, input, strict, add_trailing_slash).map(EitherMultiHostUrl::Rust)
             }
             Err(_) => {
                 // we don't need to worry about whether the url was parsed in strict mode before,
@@ -295,7 +320,7 @@ impl MultiHostUrlValidator {
                 } else if let Some(py_url) = downcast_python_input::<PyUrl>(input) {
                     self.check_length(input, || py_url.get().url().as_str().len())?;
                     Ok(EitherMultiHostUrl::Rust(PyMultiHostUrl::new(
-                        py_url.get().url().clone(),
+                        py_url.get().clone(),
                         None,
                     )))
                 } else {
@@ -365,6 +390,7 @@ fn parse_multihost_url<'py>(
     url_str: &str,
     input: &(impl Input<'py> + ?Sized),
     strict: bool,
+    add_trailing_slash: bool,
 ) -> ValResult<PyMultiHostUrl> {
     macro_rules! parsing_err {
         ($parse_error:expr) => {
@@ -454,21 +480,21 @@ fn parse_multihost_url<'py>(
     // with just one host, for consistent behaviour, we parse the URL the same as with multiple hosts
 
     let reconstructed_url = format!("{prefix}{}", &url_str[start..]);
-    let ref_url = parse_url(&reconstructed_url, input, strict)?;
+    let ref_url = parse_url(&reconstructed_url, input, strict, add_trailing_slash)?;
 
     if hosts.is_empty() {
         // if there's no one host (e.g. no `,`), we allow it to be empty to allow for default hosts
         Ok(PyMultiHostUrl::new(ref_url, None))
     } else {
         // with more than one host, none of them can be empty
-        if !ref_url.has_host() {
+        if !ref_url.url().has_host() {
             return parsing_err!(ParseError::EmptyHost);
         }
         let extra_urls: Vec<Url> = hosts
             .iter()
             .map(|host| {
                 let reconstructed_url = format!("{prefix}{host}");
-                parse_url(&reconstructed_url, input, strict)
+                parse_url(&reconstructed_url, input, strict, add_trailing_slash).map(Into::into)
             })
             .collect::<ValResult<_>>()?;
 
@@ -480,7 +506,7 @@ fn parse_multihost_url<'py>(
     }
 }
 
-fn parse_url(url_str: &str, input: impl ToErrorValue, strict: bool) -> ValResult<Url> {
+fn parse_url(url_str: &str, input: impl ToErrorValue, strict: bool, add_trailing_slash: bool) -> ValResult<PyUrl> {
     if url_str.is_empty() {
         return Err(ValError::new(
             ErrorType::UrlParsing {
@@ -490,8 +516,9 @@ fn parse_url(url_str: &str, input: impl ToErrorValue, strict: bool) -> ValResult
             input,
         ));
     }
+    let remove_trailing_slash = !add_trailing_slash && !url_str.ends_with('/');
 
-    // if we're in strict mode, we collect consider a syntax violation as an error
+    // if we're in strict mode, we consider a syntax violation as an error
     if strict {
         // we could build a vec of syntax violations and return them all, but that seems like overkill
         // and unlike other parser style validators
@@ -517,7 +544,7 @@ fn parse_url(url_str: &str, input: impl ToErrorValue, strict: bool) -> ValResult
                         input,
                     ))
                 } else {
-                    Ok(url)
+                    Ok(PyUrl::new(url, remove_trailing_slash))
                 }
             }
             Err(e) => Err(ValError::new(
@@ -529,15 +556,16 @@ fn parse_url(url_str: &str, input: impl ToErrorValue, strict: bool) -> ValResult
             )),
         }
     } else {
-        Url::parse(url_str).map_err(move |e| {
-            ValError::new(
+        match Url::parse(url_str) {
+            Ok(url) => Ok(PyUrl::new(url, remove_trailing_slash)),
+            Err(e) => Err(ValError::new(
                 ErrorType::UrlParsing {
                     error: e.to_string(),
                     context: None,
                 },
                 input,
-            )
-        })
+            )),
+        }
     }
 }
 
diff --git a/tests/validators/test_url.py b/tests/validators/test_url.py
index 13c01182a..ab5ff46cf 100644
--- a/tests/validators/test_url.py
+++ b/tests/validators/test_url.py
@@ -1,6 +1,6 @@
 import re
 from copy import deepcopy
-from typing import Optional, Union
+from typing import Any, Optional, Union
 
 import pytest
 from dirty_equals import HasRepr, IsInstance
@@ -55,12 +55,15 @@ def url_validator_fixture():
 
 
 SCHEMA_VALIDATOR_MODE = 'SCHEMA_VALIDATOR'
-URL_CLASS_MODE = 'URI_CLASS'
+URL_CLASS_MODE = 'URL_CLASS'
 MULTI_URL_CLASS_MODE = 'MULTI_URL_CLASS'
 
 
 def url_test_case_helper(
-    url: str, expected: Union[Err, str], validator_mode: str, url_validator: Optional[SchemaValidator] = None
+    url: str,
+    expected: Union[Err, str, dict[str, Any]],
+    validator_mode: str,
+    url_validator: Optional[SchemaValidator] = None,
 ):
     if isinstance(expected, Err):
         with pytest.raises(ValidationError) as exc_info:
@@ -83,6 +86,7 @@ def url_test_case_helper(
             output_url = MultiHostUrl(url)
         else:
             raise ValueError(f'Unknown validator mode: {validator_mode}')
+
         assert isinstance(output_url, (Url, MultiHostUrl))
         if isinstance(expected, str):
             assert str(output_url) == expected
@@ -271,6 +275,75 @@ def test_url_cases(url_validator, url, expected, mode):
     url_test_case_helper(url, expected, mode, url_validator)
 
 
+@pytest.fixture(scope='module', name='url_validator_trailing_slash')
+def url_validator_trailing_slash() -> SchemaValidator:
+    return SchemaValidator(core_schema.url_schema(add_trailing_slash=False))
+
+
+@pytest.mark.parametrize(
+    'url,expected',
+    [
+        ('http://example.com', 'http://example.com'),
+        ('http:example.com', 'http://example.com'),
+        ('http://example.com/', 'http://example.com/'),
+        ('http:example.com/', 'http://example.com/'),
+        ('http://example.com/path', 'http://example.com/path'),
+        ('http://example.com/path/', 'http://example.com/path/'),
+        ('http://example.com/path/?x=1', 'http://example.com/path/?x=1'),
+    ],
+)
+def test_trailing_slash(url_validator_trailing_slash: SchemaValidator, url: str, expected: str):
+    url1 = Url(url, add_trailing_slash=False)
+    assert str(url1) == expected
+    assert url1.unicode_string() == expected
+
+    url2 = url_validator_trailing_slash.validate_python(url)
+    assert str(url2) == expected
+    assert url2.unicode_string() == expected
+
+
+@pytest.fixture(scope='module', name='multi_url_validator_trailing_slash')
+def multi_url_validator_trailing_slash() -> SchemaValidator:
+    return SchemaValidator(core_schema.multi_host_url_schema(add_trailing_slash=False))
+
+
+@pytest.mark.parametrize(
+    'url,expected',
+    [
+        ('http://example.com', 'http://example.com'),
+        ('http://example.com/', 'http://example.com/'),
+        ('http://example.com/path', 'http://example.com/path'),
+        ('http://example.com/path/', 'http://example.com/path/'),
+        ('http://example.com,example.org', 'http://example.com,example.org'),
+        ('http://example.com,example.org/', 'http://example.com,example.org/'),
+        ('http://localhost,127.0.0.1', 'http://localhost,127.0.0.1'),
+        ('http://localhost,127.0.0.1/', 'http://localhost,127.0.0.1/'),
+        ('http:localhost,127.0.0.1', 'http://localhost,127.0.0.1'),
+        ('http://localhost,127.0.0.1/path', 'http://localhost,127.0.0.1/path'),
+        ('http://localhost,127.0.0.1/path/', 'http://localhost,127.0.0.1/path/'),
+    ],
+)
+def test_multi_trailing_slash(multi_url_validator_trailing_slash: SchemaValidator, url: str, expected: str):
+    url1 = MultiHostUrl(url, add_trailing_slash=False)
+    assert str(url1) == expected
+    assert url1.unicode_string() == expected
+
+    url2 = multi_url_validator_trailing_slash.validate_python(url)
+    assert str(url2) == expected
+    assert url2.unicode_string() == expected
+
+
+def test_multi_trailing_slash_config():
+    s = SchemaValidator(core_schema.url_schema(), CoreConfig(url_add_trailing_slash=False))
+    url1 = s.validate_python('http://example.com')
+    assert str(url1) == 'http://example.com'
+    assert url1.unicode_string() == 'http://example.com'
+
+    url2 = s.validate_python('http://example.com/')
+    assert str(url2) == 'http://example.com/'
+    assert url2.unicode_string() == 'http://example.com/'
+
+
 @pytest.mark.parametrize(
     'validator_kwargs,url,expected',
     [